mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-12 15:25:59 +00:00
feat(other): initial end-to-end tests working in github ci and locally (#402)
Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
This commit is contained in:
parent
f80357e45f
commit
78a8c68800
211
.github/workflows/test.e2e.yml
vendored
Normal file
211
.github/workflows/test.e2e.yml
vendored
Normal file
@ -0,0 +1,211 @@
|
||||
name: test:e2e
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
cypress-e2e-tests:
|
||||
name: Run E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tests-failed: ${{ steps.cypress-tests.outcome == 'failure' || steps.report-results.outputs.test_failed == 'true' }}
|
||||
tests-outcome: ${{ steps.cypress-tests.outcome }}
|
||||
test_failed: ${{ steps.report-results.outputs.test_failed }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@89d709d423dc495668cd762a18dd4a070611be3f # v5.0.0
|
||||
with:
|
||||
node-version-file: ./.tool-versions
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
app/package-lock.json
|
||||
lib/package-lock.json
|
||||
cypress/package-lock.json
|
||||
|
||||
- name: Build Library
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: ./lib
|
||||
|
||||
- name: Build Frontend
|
||||
run: |
|
||||
cp .env.dist .env
|
||||
sed -i '/VITE_DIRECTUS_ADMIN_ROLE=/c\VITE_DIRECTUS_ADMIN_ROLE=8141dee8-8e10-48d0-baf1-680aea271298' .env
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: ./app
|
||||
|
||||
- name: Clean Database State
|
||||
run: |
|
||||
# Remove any existing database data to ensure fresh state
|
||||
sudo rm -rf ./data/database
|
||||
mkdir -p ./data/uploads
|
||||
sudo chmod 777 -R ./data
|
||||
|
||||
- name: Build and start all Containers
|
||||
run: docker compose up -d
|
||||
|
||||
- name: Wait for Directus to be Ready
|
||||
run: |
|
||||
echo "Waiting for Directus API to be ready..."
|
||||
timeout 120 bash -c 'until curl -f http://localhost:8055/server/health; do echo "Waiting for Directus..."; sleep 5; done'
|
||||
echo "Directus is ready!"
|
||||
|
||||
- name: Seed Backend
|
||||
run: |
|
||||
mkdir -p ./data/uploads
|
||||
sudo chmod 777 -R ./data
|
||||
cd backend && ./push.sh && ./seed.sh
|
||||
working-directory: ./
|
||||
|
||||
- name: Wait for Application to be Ready
|
||||
run: |
|
||||
echo "Waiting for application to be ready..."
|
||||
timeout 300 bash -c 'until curl -f http://localhost:8080/login; do sleep 5; done'
|
||||
echo "Application is ready!"
|
||||
|
||||
- name: Health Check
|
||||
run: |
|
||||
echo "Frontend health check:"
|
||||
curl -f http://localhost:8080/login || exit 1
|
||||
echo "Backend health check:"
|
||||
curl -f http://localhost:8055/server/health || exit 1
|
||||
|
||||
- name: Install Cypress Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./cypress
|
||||
|
||||
- name: Setup Display Environment for Parallel Tests
|
||||
run: |
|
||||
echo "Setting up display environment for parallel Cypress execution..."
|
||||
|
||||
# Kill any existing Xvfb processes to ensure clean state
|
||||
sudo pkill Xvfb || true
|
||||
|
||||
# Remove any existing lock files
|
||||
sudo rm -f /tmp/.X*-lock || true
|
||||
|
||||
# Ensure xvfb is available
|
||||
which xvfb-run || (sudo apt-get update && sudo apt-get install -y xvfb)
|
||||
|
||||
echo "Display environment setup complete"
|
||||
|
||||
- name: Run E2E Tests
|
||||
id: cypress-tests
|
||||
run: |
|
||||
# Override the npm script to use xvfb-run with display isolation
|
||||
SPEC_COUNT=$(find e2e -name "*.cy.ts" | wc -l)
|
||||
echo "Running $SPEC_COUNT test chunks in parallel with display isolation"
|
||||
|
||||
# Array to store background process PIDs
|
||||
declare -a pids=()
|
||||
|
||||
# Launch parallel processes with isolated displays
|
||||
for i in $(seq 0 $((SPEC_COUNT-1))); do
|
||||
echo "Starting Cypress chunk $((i + 1))/$SPEC_COUNT on display :$((100 + i))"
|
||||
(
|
||||
SPLIT="$SPEC_COUNT" SPLIT_INDEX="$i" SPLIT_SUMMARY=false \
|
||||
xvfb-run --server-num="$((100 + i))" \
|
||||
--server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" \
|
||||
npx cypress run --e2e --browser chromium
|
||||
) &
|
||||
pids+=($!)
|
||||
done
|
||||
|
||||
# Wait for all background processes and collect exit codes
|
||||
exit_code=0
|
||||
for pid in "${pids[@]}"; do
|
||||
if ! wait "$pid"; then
|
||||
echo "Process $pid failed"
|
||||
exit_code=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All parallel test processes completed"
|
||||
|
||||
# Exit with failure if any test failed
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo "❌ Some tests failed"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All tests passed"
|
||||
fi
|
||||
working-directory: ./cypress
|
||||
env:
|
||||
# Disable individual cypress-split summaries to avoid conflicts
|
||||
SPLIT_SUMMARY: false
|
||||
|
||||
- name: Upload test artifacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50 # v4.6.2
|
||||
with:
|
||||
name: cypress-test-results-${{ github.run_id }}
|
||||
path: |
|
||||
cypress/results/
|
||||
cypress/screenshots/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
process-test-reports:
|
||||
name: Process Test Reports
|
||||
runs-on: ubuntu-latest
|
||||
needs: cypress-e2e-tests
|
||||
if: failure() && needs.cypress-e2e-tests.result == 'failure'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@89d709d423dc495668cd762a18dd4a070611be3f # v5.0.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: cypress/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./cypress
|
||||
|
||||
- name: Download test artifacts
|
||||
uses: actions/download-artifact@4a24838f3d5601fd639834081e118c2995d51e1c # v5.0.0
|
||||
with:
|
||||
name: cypress-test-results-${{ github.run_id }}
|
||||
path: ./cypress
|
||||
|
||||
- name: Merge JSON reports into one consolidated report
|
||||
run: ./scripts/merge-reports.sh
|
||||
working-directory: ./cypress
|
||||
|
||||
- name: Generate HTML report with screenshots
|
||||
run: ./scripts/generate-html-report.sh
|
||||
working-directory: ./cypress
|
||||
|
||||
- name: Create simple index page
|
||||
run: ./scripts/create-index-page.sh
|
||||
working-directory: ./cypress
|
||||
env:
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
|
||||
- name: Upload consolidated test report
|
||||
uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50 # v4.6.2
|
||||
with:
|
||||
name: e2e-test-report-${{ github.run_id }}
|
||||
path: cypress/results/html/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload raw test data (for debugging)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50 # v4.6.2
|
||||
with:
|
||||
name: e2e-raw-data-${{ github.run_id }}
|
||||
path: |
|
||||
cypress/results/
|
||||
cypress/screenshots/
|
||||
cypress/videos/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
34
.github/workflows/test.lint.cypress.yml
vendored
Normal file
34
.github/workflows/test.lint.cypress.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
name: test:lint:cypress
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Detect File Changes - lint - cypress
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cypress: ${{ steps.filter.outputs.cypress }}
|
||||
steps:
|
||||
- uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
cypress:
|
||||
- '.github/workflows/test.lint.cypress.yml'
|
||||
- '.github/workflows/test.e2e.yml'
|
||||
- 'cypress/**/*'
|
||||
|
||||
lint:
|
||||
if: needs.files-changed.outputs.cypress == 'true'
|
||||
name: Lint - cypress
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0
|
||||
- uses: actions/setup-node@89d709d423dc495668cd762a18dd4a070611be3f # v5.0.0
|
||||
with:
|
||||
node-version-file: '.tool-versions'
|
||||
- name: Lint
|
||||
run: npm install && npm run lint
|
||||
working-directory: cypress/
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,2 +1,6 @@
|
||||
.claude/
|
||||
data/
|
||||
cypress/node_modules/
|
||||
cypress/results/
|
||||
cypress/runner-results/
|
||||
cypress/screenshots/
|
||||
|
||||
@ -37,11 +37,6 @@ npm run test:unit:dev # Run Vitest in watch mode
|
||||
npm run docs:generate # Generate TypeDoc documentation
|
||||
```
|
||||
|
||||
### Root Level
|
||||
```bash
|
||||
./scripts/check-lint.sh # Run linting on both app and lib (used by PR hooks)
|
||||
```
|
||||
|
||||
### Backend (Directus)
|
||||
```bash
|
||||
cd app
|
||||
@ -94,9 +89,9 @@ npx directus-sync push --directus-url http://localhost:8055 --directus-email adm
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit Tests**: Vitest for lib components with coverage reporting
|
||||
- **Component Tests**: Cypress for React component integration
|
||||
- **Linting**: ESLint with TypeScript rules for code quality
|
||||
- **Type Checking**: TypeScript strict mode across all packages
|
||||
- **End-to-End Tests**: Cypress for testing the app's UI and user flows
|
||||
|
||||
### Import Conventions
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ RUN apk add python3 g++ make
|
||||
WORKDIR /extensions
|
||||
ADD extensions .
|
||||
RUN npm install
|
||||
# Move all extensions the starts with directus-extension-, using find, to the /extensions/directus folder
|
||||
# Move all extensions that start with directus-extension-, using find, to the /extensions/directus folder
|
||||
RUN mkdir -p ./directus
|
||||
RUN cd node_modules && find . -maxdepth 1 -type d -name "directus-extension-*" -exec mv {} ../directus \;
|
||||
|
||||
|
||||
@ -51,6 +51,133 @@
|
||||
]
|
||||
},
|
||||
"layer" : "layer-people"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-places-2",
|
||||
"name": "Community Garden Berlin",
|
||||
"subname": "Organic vegetables and sustainable farming",
|
||||
"text": "A thriving community garden in Berlin where neighbors grow organic vegetables together. We focus on #sustainability #community #gardening #organic practices. Join us for weekly workshops on #permaculture and #composting!",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
13.404954,
|
||||
52.520008
|
||||
]
|
||||
},
|
||||
"layer" : "layer-places"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-event-2",
|
||||
"name": "Tech Meetup Munich",
|
||||
"text": "Monthly #technology #networking event for developers and tech enthusiasts. Topics include #javascript #opensource #webdev and #innovation. Great for #learning and meeting like-minded people!",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
11.576124,
|
||||
48.137154
|
||||
]
|
||||
},
|
||||
"layer" : "layer-events",
|
||||
"start": "2025-12-15T18:00:00",
|
||||
"end": "2025-12-15T21:00:00"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-people-2",
|
||||
"name": "Jane Developer",
|
||||
"text": "Full-stack developer passionate about #opensource projects and #javascript frameworks. Interested in #sustainability tech and #community building. Always happy to collaborate on #innovation projects!",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
2.349014,
|
||||
48.864716
|
||||
]
|
||||
},
|
||||
"layer" : "layer-people"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-places-3",
|
||||
"name": "Café Collaboration London",
|
||||
"subname": "Co-working space and coffee shop",
|
||||
"text": "A vibrant co-working café in London perfect for #networking and #collaboration. Features excellent coffee, fast wifi, and a #community of freelancers and entrepreneurs. Regular #events and workshops on #business and #creativity.",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
-0.127758,
|
||||
51.507351
|
||||
]
|
||||
},
|
||||
"layer" : "layer-places"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-event-3",
|
||||
"name": "Sustainability Workshop NYC",
|
||||
"text": "Learn about #sustainability practices and #environmental solutions in this hands-on workshop. Topics include #recycling #renewable energy and #green living. Perfect for #activists and #eco enthusiasts!",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
-74.005941,
|
||||
40.712784
|
||||
]
|
||||
},
|
||||
"layer" : "layer-events",
|
||||
"start": "2025-11-20T14:00:00",
|
||||
"end": "2025-11-20T17:00:00"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-people-3",
|
||||
"name": "Alex Entrepreneur",
|
||||
"text": "Serial entrepreneur focused on #sustainability and #innovation. Building #community-driven solutions for #environmental challenges. Always looking for #collaboration opportunities in #greentech!",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
-122.419416,
|
||||
37.774929
|
||||
]
|
||||
},
|
||||
"layer" : "layer-people"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-places-4",
|
||||
"name": "Makerspace Tokyo",
|
||||
"subname": "Innovation hub for creators",
|
||||
"text": "State-of-the-art makerspace in Tokyo with 3D printers, laser cutters, and electronics lab. Perfect for #makers #innovation #prototyping and #learning. Join our #community of inventors and #entrepreneurs!",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
139.691706,
|
||||
35.689487
|
||||
]
|
||||
},
|
||||
"layer" : "layer-places"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-event-4",
|
||||
"name": "Open Source Conference",
|
||||
"text": "Annual conference celebrating #opensource software and #collaboration. Features talks on #javascript #python #webdev and #community building. Great for #developers #learning and #networking!",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
-0.118092,
|
||||
51.509865
|
||||
]
|
||||
},
|
||||
"layer" : "layer-events",
|
||||
"start": "2025-10-10T09:00:00",
|
||||
"end": "2025-10-12T18:00:00"
|
||||
},
|
||||
{
|
||||
"_sync_id": "item-places-5",
|
||||
"name": "Test & Special Characters!",
|
||||
"subname": "Edge case for search testing",
|
||||
"text": "This item tests special characters: @#$%^&*()_+ and unicode: café naïve résumé. Also tests very long content that might affect search performance and display. Contains #testing #edgecase #unicode #special-chars tags.",
|
||||
"position": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
4.902257,
|
||||
52.367573
|
||||
]
|
||||
},
|
||||
"layer" : "layer-places"
|
||||
}
|
||||
]
|
||||
}
|
||||
169
cypress/README.md
Normal file
169
cypress/README.md
Normal file
@ -0,0 +1,169 @@
|
||||
# Cypress End-to-End Tests
|
||||
|
||||
This directory contains **end-to-end tests** for the **Utopia Map application** using [Cypress](https://www.cypress.io/).
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Cypress** - E2E testing framework with TypeScript
|
||||
- **cypress-split** - Parallel test execution for faster runs - in the CI pipeline and locally
|
||||
- **mochawesome** - HTML report generation with embedded screenshots for the Github CI pipeline
|
||||
|
||||
## GitHub CI Integration
|
||||
|
||||
Tests run automatically on every push via the `.github/workflows/test.e2e.yml` workflow:
|
||||
|
||||
1. **Build** - Compiles the library and frontend application
|
||||
2. **Start Services** - Launches Docker stack (frontend, backend, database)
|
||||
3. **Seed Data** - Populates database with test data
|
||||
4. **Run Tests** - Executes tests in parallel using `cypress-split`
|
||||
5. **Generate Reports** - Creates consolidated HTML report with screenshots
|
||||
6. **Upload Artifacts** - On failure, uploads test reports for debugging
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
Tests run in parallel using [cypress-split](https://github.com/bahmutov/cypress-split), automatically distributing spec files across multiple processes to reduce execution time.
|
||||
|
||||
### Test Reports
|
||||
|
||||
When tests fail, GitHub Actions automatically uploads as artefact:
|
||||
- **HTML Report**
|
||||
- Interactive test results with embedded screenshots
|
||||
- Available for 14 days with unique naming: `e2e-test-report-{run-id}`
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (version from `.tool-versions`)
|
||||
- Docker and Docker Compose
|
||||
- Sufficient disk space (~2GB for Docker images)
|
||||
|
||||
### Headless Mode (CI-like)
|
||||
|
||||
Run tests without GUI, replicating the CI environment:
|
||||
|
||||
```bash
|
||||
# 1. Set Node.js version
|
||||
nvm use
|
||||
|
||||
# 2. Build the library and frontend
|
||||
cd lib && npm install && npm run build && cd ..
|
||||
|
||||
# 3.Build the frontend && cd ..
|
||||
cd app && cp .env.dist .env
|
||||
sed -i '/VITE_DIRECTUS_ADMIN_ROLE=/c\VITE_DIRECTUS_ADMIN_ROLE=8141dee8-8e10-48d0-baf1-680aea271298' .env
|
||||
npm ci && npm run build && cd ..
|
||||
|
||||
# 3. Start Docker services
|
||||
docker compose up -d
|
||||
|
||||
# 4. Wait for services and seed data
|
||||
timeout 120 bash -c 'until curl -f http://localhost:8055/server/health; do sleep 5; done'
|
||||
cd backend && ./seed.sh && cd ..
|
||||
|
||||
# 5. Run tests
|
||||
cd cypress && npm ci
|
||||
|
||||
# Run all tests in parallel (like CI)
|
||||
npm run test:split:auto
|
||||
|
||||
# Or run tests sequentially
|
||||
npm test
|
||||
|
||||
# Or run specific test file
|
||||
npx cypress run --e2e --browser chromium --spec "e2e/authentication/login.cy.ts"
|
||||
```
|
||||
|
||||
### GUI Mode (Development)
|
||||
|
||||
Run tests with interactive GUI for debugging:
|
||||
|
||||
```bash
|
||||
# 1-4. Follow steps 1-4 from headless mode above
|
||||
|
||||
# 5. Open Cypress GUI
|
||||
cd cypress && npm ci && npm run test:open
|
||||
```
|
||||
|
||||
#### GUI Features
|
||||
|
||||
- **Live Reload** - Tests auto-reload when you save changes
|
||||
- **Time Travel** - Click commands to see DOM snapshots
|
||||
- **Selector Playground** - Interactive tool to find element selectors
|
||||
- **Network Inspection** - View all XHR/fetch requests
|
||||
- **Debug Tools** - Use browser DevTools for debugging
|
||||
|
||||
### Cleanup
|
||||
|
||||
```bash
|
||||
# Stop containers
|
||||
docker compose down
|
||||
|
||||
# Remove database data (for fresh start)
|
||||
sudo rm -rf ./data/database
|
||||
```
|
||||
|
||||
## Test Reports
|
||||
|
||||
After running tests, reports are generated in `cypress/results/`:
|
||||
|
||||
- **HTML Report** - `results/html/merged-report.html` - Interactive report with charts
|
||||
- **Screenshots** - `results/html/screenshots/` - Failure screenshots embedded in HTML
|
||||
- **JSON Data** - `results/*.json` - Raw test data for custom processing
|
||||
|
||||
Generate reports manually:
|
||||
```bash
|
||||
npm run report:merge # Merge parallel test results
|
||||
npm run report:generate # Create HTML report
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Tests are located in `cypress/e2e/` and follow this structure:
|
||||
|
||||
```typescript
|
||||
describe('Feature Name', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearCookies()
|
||||
cy.clearLocalStorage()
|
||||
cy.visit('/page-url')
|
||||
})
|
||||
|
||||
it('should perform expected behavior', () => {
|
||||
cy.get('[data-cy="input"]').type('value')
|
||||
cy.get('[data-cy="submit"]').click()
|
||||
cy.url().should('include', '/success')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Use `[data-cy="..."]` selectors for stability
|
||||
- Test user behavior, rather than just implementation details
|
||||
- Keep tests isolated and independent
|
||||
- Use custom commands for common patterns
|
||||
- Clear state in `beforeEach` hooks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Tests fail with "baseUrl not reachable"**
|
||||
```bash
|
||||
cd app && npm run build && cd .. && docker compose up -d
|
||||
```
|
||||
|
||||
**Backend health check fails**
|
||||
```bash
|
||||
docker compose logs backend
|
||||
docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
**Seeding fails with "ConflictError"**
|
||||
```bash
|
||||
docker compose down && sudo rm -rf ./data/database && docker compose up -d
|
||||
```
|
||||
|
||||
**Permission denied on ./data**
|
||||
```bash
|
||||
sudo chmod 777 -R ./data
|
||||
```
|
||||
67
cypress/cypress.config.ts
Normal file
67
cypress/cypress.config.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { defineConfig } from 'cypress'
|
||||
const cypressSplit = require('cypress-split')
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:8080',
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
|
||||
specPattern: 'e2e/**/*.cy.ts',
|
||||
supportFile: 'support/e2e.ts',
|
||||
screenshotsFolder: 'screenshots',
|
||||
videosFolder: 'videos',
|
||||
video: false,
|
||||
screenshotOnRunFailure: true,
|
||||
|
||||
reporter: 'mochawesome',
|
||||
reporterOptions: {
|
||||
reportDir: 'results',
|
||||
reportFilename: '[name]',
|
||||
overwrite: false,
|
||||
html: false, // Only generate JSON during test runs for merging
|
||||
json: true, // Generate JSON for merging
|
||||
embeddedScreenshots: true,
|
||||
useInlineDiffs: true,
|
||||
screenshotOnRunFailure: true
|
||||
},
|
||||
|
||||
defaultCommandTimeout: 10000,
|
||||
requestTimeout: 10000,
|
||||
responseTimeout: 10000,
|
||||
pageLoadTimeout: 30000,
|
||||
|
||||
testIsolation: true,
|
||||
|
||||
retries: {
|
||||
runMode: 2,
|
||||
openMode: 0
|
||||
},
|
||||
|
||||
env: {
|
||||
apiUrl: 'http://localhost:8055',
|
||||
validEmail: 'admin@it4c.dev',
|
||||
validPassword: 'admin123',
|
||||
invalidEmail: 'invalid@example.com',
|
||||
invalidPassword: 'wrongpassword'
|
||||
},
|
||||
|
||||
setupNodeEvents(on, config) {
|
||||
// Load cypress-split plugin
|
||||
cypressSplit(on, config)
|
||||
|
||||
// Load parallel reporter plugin
|
||||
const parallelReporter = require('./plugins/parallel-reporter')
|
||||
config = parallelReporter(on, config)
|
||||
|
||||
on('task', {
|
||||
log(message) {
|
||||
console.log(message)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
return config
|
||||
},
|
||||
},
|
||||
})
|
||||
166
cypress/e2e/authentification/login.cy.ts
Normal file
166
cypress/e2e/authentification/login.cy.ts
Normal file
@ -0,0 +1,166 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('Utopia Map Login', () => {
|
||||
const testData = {
|
||||
validUser: {
|
||||
email: Cypress.env('validEmail'),
|
||||
password: Cypress.env('validPassword')
|
||||
},
|
||||
invalidUser: {
|
||||
email: Cypress.env('invalidEmail'),
|
||||
password: Cypress.env('invalidPassword')
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cy.clearCookies()
|
||||
cy.clearLocalStorage()
|
||||
cy.window().then((win) => {
|
||||
win.sessionStorage.clear()
|
||||
})
|
||||
|
||||
cy.visit('/login')
|
||||
})
|
||||
|
||||
it('should successfully login with valid credentials', () => {
|
||||
cy.intercept('POST', '**/auth/login').as('loginRequest')
|
||||
|
||||
cy.get('input[type="email"]').clear()
|
||||
cy.get('input[type="email"]').type(testData.validUser.email)
|
||||
cy.get('input[type="password"]').clear()
|
||||
cy.get('input[type="password"]').type(testData.validUser.password)
|
||||
cy.get('button:contains("Login")').click()
|
||||
|
||||
cy.wait('@loginRequest').then((interception) => {
|
||||
expect(interception.response?.statusCode).to.eq(200)
|
||||
expect(interception.request.body).to.deep.include({
|
||||
email: testData.validUser.email,
|
||||
password: testData.validUser.password
|
||||
})
|
||||
|
||||
expect(interception.response?.body).to.have.property('data')
|
||||
expect(interception.response?.body.data).to.have.property('access_token')
|
||||
expect(interception.response?.body.data.access_token).to.be.a('string')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
expect(interception.response?.body.data.access_token).to.not.be.empty
|
||||
})
|
||||
|
||||
cy.get('.Toastify__toast--success', { timeout: 10000 }).should('be.visible')
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/')
|
||||
})
|
||||
|
||||
it('should show error for missing password', () => {
|
||||
cy.intercept('POST', '**/auth/login').as('loginRequest')
|
||||
|
||||
cy.get('input[type="email"]').type(testData.validUser.email)
|
||||
cy.get('button:contains("Login")').click()
|
||||
|
||||
cy.wait('@loginRequest').then((interception) => {
|
||||
expect(interception.response?.statusCode).to.eq(400)
|
||||
expect(interception.request.body).to.deep.include({
|
||||
email: testData.validUser.email,
|
||||
password: ''
|
||||
})
|
||||
|
||||
expect(interception.response?.body).to.have.property('errors')
|
||||
expect(interception.response?.body.errors).to.be.an('array')
|
||||
expect(interception.response?.body.errors).to.have.length.greaterThan(0)
|
||||
expect(interception.response?.body.errors[0]).to.have.property('message')
|
||||
})
|
||||
|
||||
cy.get('.Toastify__toast--error', { timeout: 10000 }).should('be.visible')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
|
||||
it('should show error for missing email', () => {
|
||||
cy.intercept('POST', '**/auth/login').as('loginRequest')
|
||||
|
||||
cy.get('input[type="password"]').type(testData.validUser.password)
|
||||
cy.get('button:contains("Login")').click()
|
||||
|
||||
cy.wait('@loginRequest').then((interception) => {
|
||||
expect(interception.response?.statusCode).to.eq(400)
|
||||
expect(interception.request.body).to.deep.include({
|
||||
email: '',
|
||||
password: testData.validUser.password
|
||||
})
|
||||
|
||||
expect(interception.response?.body).to.have.property('errors')
|
||||
expect(interception.response?.body.errors).to.be.an('array')
|
||||
expect(interception.response?.body.errors).to.have.length.greaterThan(0)
|
||||
})
|
||||
|
||||
cy.get('.Toastify__toast--error', { timeout: 10000 }).should('be.visible')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
|
||||
it('should show error for missing credentials', () => {
|
||||
cy.intercept('POST', '**/auth/login').as('loginRequest')
|
||||
|
||||
cy.get('button:contains("Login")').click()
|
||||
|
||||
cy.wait('@loginRequest').then((interception) => {
|
||||
expect(interception.response?.statusCode).to.eq(400)
|
||||
expect(interception.request.body).to.deep.include({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
expect(interception.response?.body).to.have.property('errors')
|
||||
expect(interception.response?.body.errors).to.be.an('array')
|
||||
expect(interception.response?.body.errors).to.have.length.greaterThan(0)
|
||||
})
|
||||
|
||||
cy.get('.Toastify__toast--error', { timeout: 10000 }).should('be.visible')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
|
||||
it('should show error for invalid credentials', () => {
|
||||
cy.intercept('POST', '**/auth/login').as('loginRequest')
|
||||
|
||||
cy.get('input[type="email"]').clear()
|
||||
cy.get('input[type="email"]').type(testData.invalidUser.email)
|
||||
cy.get('input[type="password"]').clear()
|
||||
cy.get('input[type="password"]').type(testData.invalidUser.password)
|
||||
cy.get('button:contains("Login")').click()
|
||||
|
||||
cy.wait('@loginRequest').then((interception) => {
|
||||
expect(interception.response?.statusCode).to.eq(401)
|
||||
expect(interception.request.body).to.deep.include({
|
||||
email: testData.invalidUser.email,
|
||||
password: testData.invalidUser.password
|
||||
})
|
||||
|
||||
expect(interception.response?.body).to.have.property('errors')
|
||||
expect(interception.response?.body.errors).to.be.an('array')
|
||||
expect(interception.response?.body.errors[0]).to.have.property('message')
|
||||
expect(interception.response?.body.errors[0].message).to.contain('Invalid user credentials')
|
||||
})
|
||||
|
||||
cy.get('.Toastify__toast--error', { timeout: 10000 }).should('be.visible')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
|
||||
it('should show loading state during login', () => {
|
||||
cy.intercept('POST', '**/auth/login', {
|
||||
delay: 1000,
|
||||
statusCode: 200,
|
||||
body: {
|
||||
data: {
|
||||
access_token: 'test_token_123',
|
||||
expires: 900000,
|
||||
refresh_token: 'refresh_token_123'
|
||||
}
|
||||
}
|
||||
}).as('loginRequest')
|
||||
|
||||
cy.get('input[type="email"]').type(testData.validUser.email)
|
||||
cy.get('input[type="password"]').type(testData.validUser.password)
|
||||
cy.get('button:contains("Login")').click()
|
||||
|
||||
cy.get('.tw\\:loading-spinner', { timeout: 5000 }).should('be.visible')
|
||||
|
||||
cy.wait('@loginRequest')
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/')
|
||||
})
|
||||
})
|
||||
33
cypress/e2e/authentification/login.form-elements.cy.ts
Normal file
33
cypress/e2e/authentification/login.form-elements.cy.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('Utopia Map Login Form Elements', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
cy.clearCookies()
|
||||
cy.clearLocalStorage()
|
||||
cy.window().then((win) => {
|
||||
win.sessionStorage.clear()
|
||||
})
|
||||
|
||||
cy.visit('/login')
|
||||
})
|
||||
|
||||
it('should be displayed correctly', () => {
|
||||
cy.get('h2').should('contain.text', 'Login')
|
||||
cy.get('input[type="email"]')
|
||||
.should('be.visible')
|
||||
.should('have.attr', 'placeholder', 'E-Mail')
|
||||
|
||||
cy.get('input[type="password"]')
|
||||
.should('be.visible')
|
||||
.should('have.attr', 'placeholder', 'Password')
|
||||
|
||||
cy.get('button:contains("Login")')
|
||||
.should('be.visible')
|
||||
.should('not.be.disabled')
|
||||
|
||||
cy.get('a[href="/reset-password"]')
|
||||
.should('be.visible')
|
||||
.should('contain.text', 'Forgot Password?')
|
||||
})
|
||||
})
|
||||
115
cypress/e2e/search/search-flows.cy.ts
Normal file
115
cypress/e2e/search/search-flows.cy.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('Utopia Map Search', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearCookies()
|
||||
cy.clearLocalStorage()
|
||||
cy.window().then((win) => {
|
||||
win.sessionStorage.clear()
|
||||
})
|
||||
|
||||
cy.visit('/')
|
||||
cy.waitForMapReady()
|
||||
})
|
||||
|
||||
describe('Item Search', () => {
|
||||
it('should find items by exact name match', () => {
|
||||
cy.searchFor('Tech Meetup Munich')
|
||||
cy.get('[data-cy="search-suggestions"]').should('contain', 'Tech Meetup Munich')
|
||||
})
|
||||
|
||||
it('should find items by partial name match (case insensitive)', () => {
|
||||
cy.searchFor('café collaboration')
|
||||
cy.get('[data-cy="search-suggestions"]').should('contain', 'Café Collaboration London')
|
||||
})
|
||||
|
||||
it('should find items by text content', () => {
|
||||
cy.searchFor('sustainability')
|
||||
cy.get('[data-cy="search-suggestions"]').should('contain', 'Alex Entrepreneur')
|
||||
})
|
||||
|
||||
it('should navigate to item profile when clicking search result', () => {
|
||||
cy.searchFor('welcome')
|
||||
cy.get('[data-cy="search-suggestions"]').within(() => {
|
||||
cy.get('[data-cy="search-item-result"]').contains('Welcome to Utopia Map').click()
|
||||
})
|
||||
|
||||
cy.url().should('match', /\/(item\/|[a-f0-9-]+)/)
|
||||
cy.get('body').should('contain', 'Welcome to Utopia Map')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Geographic Search', () => {
|
||||
it('should find geographic locations and related items for a city', () => {
|
||||
cy.searchFor('Berlin')
|
||||
|
||||
cy.get('[data-cy="search-suggestions"]').within(() => {
|
||||
cy.get('[data-cy="search-geo-result"]').should('contain', 'Berlin')
|
||||
cy.get('[data-cy="search-item-result"]').should('contain', 'Community Garden Berlin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to geographic location when clicking search result', () => {
|
||||
cy.searchFor('Berlin')
|
||||
|
||||
// Click geographic result -> temporary marker
|
||||
cy.get('[data-cy="search-suggestions"]').within(() => {
|
||||
cy.get('[data-cy="search-geo-result"]').contains('Berlin').click()
|
||||
})
|
||||
|
||||
// User sees temporary marker with location popup
|
||||
cy.get('.leaflet-popup').should('be.visible')
|
||||
cy.get('.leaflet-popup-content').should('contain', 'Berlin')
|
||||
|
||||
// Search input is blurred and suggestions hidden
|
||||
cy.get('[data-cy="search-input"]').should('not.be.focused')
|
||||
cy.get('[data-cy="search-suggestions"]').should('not.exist')
|
||||
})
|
||||
|
||||
it('should find specific addresses and landmarks', () => {
|
||||
cy.searchFor('Wat Arun')
|
||||
cy.get('[data-cy="search-suggestions"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="search-suggestions"]').within(() => {
|
||||
cy.contains('Wat Arun').first().click()
|
||||
})
|
||||
|
||||
cy.get('.leaflet-popup').should('be.visible')
|
||||
cy.get('.leaflet-popup-content').should('contain', 'Wat Arun')
|
||||
})
|
||||
|
||||
it('should navigate to precise coordinates', () => {
|
||||
const coordinates = '52.5200,13.4050'
|
||||
cy.searchFor(coordinates)
|
||||
|
||||
// User sees coordinate option with flag icon
|
||||
cy.get('[data-cy="search-suggestions"]').within(() => {
|
||||
cy.get('[data-cy="search-coordinate-result"]').should('contain', coordinates)
|
||||
cy.get('[data-cy="search-coordinate-icon"]').should('exist')
|
||||
cy.get('[data-cy="search-coordinate-result"]').click()
|
||||
})
|
||||
|
||||
cy.get('.leaflet-popup').should('be.visible')
|
||||
cy.get('.leaflet-popup-content').should('contain.text', '52.52, 13.40')
|
||||
})
|
||||
|
||||
it('should differentiate between database items and geographic locations', () => {
|
||||
cy.searchFor('Berlin')
|
||||
|
||||
cy.get('[data-cy="search-suggestions"]').within(() => {
|
||||
// Database item should have custom icon and simple name
|
||||
cy.get('[data-cy="search-item-result"]').first().within(() => {
|
||||
// Should have either an icon or placeholder for database items
|
||||
cy.get('[data-cy="search-item-icon"], [data-cy="search-item-icon-placeholder"]').should('exist')
|
||||
})
|
||||
cy.get('[data-cy="search-item-result"]').should('contain', 'Community Garden Berlin')
|
||||
|
||||
// Geographic result should have magnifying glass icon and detailed info
|
||||
cy.get('[data-cy="search-geo-result"]').first().within(() => {
|
||||
cy.get('[data-cy="search-geo-icon"]').should('exist')
|
||||
cy.get('[data-cy="search-geo-details"]').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
133
cypress/eslint.config.mjs
Normal file
133
cypress/eslint.config.mjs
Normal file
@ -0,0 +1,133 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import pluginCypress from 'eslint-plugin-cypress'
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.cy.{js,jsx,ts,tsx}', 'cypress/**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
cypress: pluginCypress,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
cy: 'readonly',
|
||||
Cypress: 'readonly',
|
||||
expect: 'readonly',
|
||||
assert: 'readonly',
|
||||
chai: 'readonly',
|
||||
describe: 'readonly',
|
||||
it: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
before: 'readonly',
|
||||
after: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'cypress/no-assigning-return-values': 'error',
|
||||
'cypress/no-unnecessary-waiting': 'error',
|
||||
'cypress/no-async-tests': 'error',
|
||||
'cypress/unsafe-to-chain-command': 'error',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
'argsIgnorePattern': '^_',
|
||||
'varsIgnorePattern': '^_'
|
||||
}],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
'no-console': 'warn',
|
||||
'no-debugger': 'error',
|
||||
'no-unused-vars': 'warn',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'eqeqeq': ['error', 'always'],
|
||||
'curly': ['error', 'all'],
|
||||
|
||||
'indent': ['error', 2],
|
||||
'quotes': ['error', 'single', { 'avoidEscape': true }],
|
||||
'semi': ['error', 'never'],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'space-before-function-paren': ['error', 'never'],
|
||||
'keyword-spacing': ['error', { 'before': true, 'after': true }],
|
||||
'space-infix-ops': 'error',
|
||||
'eol-last': ['error', 'always'],
|
||||
'no-trailing-spaces': 'error',
|
||||
'max-len': ['warn', {
|
||||
'code': 100,
|
||||
'ignoreUrls': true,
|
||||
'ignoreStrings': true,
|
||||
'ignoreTemplateLiterals': true
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
files: ['cypress/support/**/*.{js,ts}'],
|
||||
rules: {
|
||||
// Enable console warnings in support files
|
||||
'no-console': 'warn'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
files: ['cypress.config.{js,ts}', 'eslint.config.js'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// Node.js CommonJS files (plugins, etc.) - exclude TypeScript rules
|
||||
{
|
||||
files: ['plugins/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
require: 'readonly',
|
||||
module: 'readonly',
|
||||
exports: 'readonly',
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
global: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// Disable TypeScript-specific rules for CommonJS files
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
// Allow CommonJS patterns
|
||||
'no-undef': 'off',
|
||||
'no-console': 'off',
|
||||
|
||||
// Keep basic JS rules
|
||||
'no-unused-vars': 'warn',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'cypress/downloads/**',
|
||||
'cypress/screenshots/**',
|
||||
'cypress/videos/**',
|
||||
'cypress/plugins/**', // Ignore Node.js CommonJS plugin files
|
||||
'results/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'*.min.js'
|
||||
]
|
||||
}
|
||||
)
|
||||
6457
cypress/package-lock.json
generated
Normal file
6457
cypress/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
cypress/package.json
Normal file
37
cypress/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "utopia-map-e2e-testing",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Cypress End-to-End Tests for Utopia Map",
|
||||
"scripts": {
|
||||
"test": "cypress run --e2e --browser chromium",
|
||||
"test:open": "cypress open --e2e",
|
||||
"report:merge": "mochawesome-merge 'results/*.json' -o results/merged-report.json",
|
||||
"report:generate": "marge results/merged-report.json --reportDir results/html --reportTitle 'Utopia Map E2E Tests' --reportPageTitle 'Utopia Map E2E Test Report' --inline --charts --showPassed --showFailed --showPending --showSkipped",
|
||||
"report:full": "npm run report:merge && npm run report:generate",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"keywords": [
|
||||
"cypress",
|
||||
"cypress-split",
|
||||
"e2e",
|
||||
"mochawesome",
|
||||
"testing",
|
||||
"utopia-map"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/mochawesome": "^6.2.4",
|
||||
"@types/node": "^24.5.2",
|
||||
"cypress": "^15.3.0",
|
||||
"cypress-split": "^1.24.23",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-cypress": "^5.1.1",
|
||||
"mochawesome": "^7.1.4",
|
||||
"mochawesome-merge": "^4.3.0",
|
||||
"mochawesome-report-generator": "^6.2.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1"
|
||||
}
|
||||
}
|
||||
71
cypress/plugins/parallel-reporter.js
Normal file
71
cypress/plugins/parallel-reporter.js
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Cypress Plugin for Enhanced Parallel Test Reporting
|
||||
* Handles mochawesome report generation for parallel test execution
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
module.exports = (on, config) => {
|
||||
// Ensure results directory exists
|
||||
const resultsDir = path.join(config.projectRoot, 'results')
|
||||
if (!fs.existsSync(resultsDir)) {
|
||||
fs.mkdirSync(resultsDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Configure mochawesome for parallel execution - JSON only for merging
|
||||
const splitIndex = process.env.SPLIT_INDEX || '0'
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
|
||||
config.reporterOptions = {
|
||||
...config.reporterOptions,
|
||||
reportFilename: `split-${splitIndex}-${timestamp}-[name]`,
|
||||
reportDir: resultsDir,
|
||||
overwrite: false,
|
||||
html: false, // No individual HTML files
|
||||
json: true, // Only JSON for merging into one report
|
||||
embeddedScreenshots: true,
|
||||
useInlineDiffs: true
|
||||
}
|
||||
|
||||
// Task for logging parallel execution info
|
||||
on('task', {
|
||||
log(message) {
|
||||
console.log(`[Parallel ${splitIndex}] ${message}`)
|
||||
return null
|
||||
},
|
||||
|
||||
logReportInfo() {
|
||||
console.log(`[Parallel ${splitIndex}] Report will be saved as: report-${splitIndex}-${timestamp}.json`)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Before run hook
|
||||
on('before:run', (details) => {
|
||||
console.log(`[Parallel ${splitIndex}] Starting test execution`)
|
||||
console.log(`[Parallel ${splitIndex}] Browser: ${details.browser.name}`)
|
||||
console.log(`[Parallel ${splitIndex}] Specs: ${details.specs.length}`)
|
||||
return details
|
||||
})
|
||||
|
||||
// After run hook
|
||||
on('after:run', (results) => {
|
||||
console.log(`[Parallel ${splitIndex}] Test execution completed`)
|
||||
console.log(`[Parallel ${splitIndex}] Total tests: ${results.totalTests}`)
|
||||
console.log(`[Parallel ${splitIndex}] Passed: ${results.totalPassed}`)
|
||||
console.log(`[Parallel ${splitIndex}] Failed: ${results.totalFailed}`)
|
||||
|
||||
// Ensure the report file was created
|
||||
const reportFile = path.join(resultsDir, `report-${splitIndex}-${timestamp}.json`)
|
||||
if (fs.existsSync(reportFile)) {
|
||||
console.log(`[Parallel ${splitIndex}] ✅ Report saved: ${reportFile}`)
|
||||
} else {
|
||||
console.log(`[Parallel ${splitIndex}] ❌ Report not found: ${reportFile}`)
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
return config
|
||||
}
|
||||
71
cypress/scripts/create-index-page.sh
Executable file
71
cypress/scripts/create-index-page.sh
Executable file
@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Creating index page for consolidated report ==="
|
||||
|
||||
cat > results/html/index.html << 'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Utopia Map E2E Test Report</title>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; background: #f8f9fa; }
|
||||
.header { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px #00000020; }
|
||||
.report-section { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px #00000020; }
|
||||
.report-link { display: block; padding: 16px 20px; margin: 12px 0; background: #e3f2fd; border-radius: 8px; text-decoration: none; color: #1976d2; border-left: 4px solid #2196f3; font-size: 18px; font-weight: 500; }
|
||||
.report-link:hover { background: #bbdefb; }
|
||||
.meta { color: #666; font-size: 14px; margin: 4px 0; }
|
||||
.status { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
|
||||
.status.failed { background: #ffebee; color: #c62828; }
|
||||
.status.passed { background: #e8f5e8; color: #2e7d32; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Utopia Map E2E Test Report</h1>
|
||||
<div class="meta">Generated: $(date)</div>
|
||||
<div class="meta">Run ID: ${GITHUB_RUN_ID:-unknown}</div>
|
||||
<div class="meta">Commit: ${GITHUB_SHA:-unknown}</div>
|
||||
<div class="meta">Status: <span class="status failed">Tests Failed</span></div>
|
||||
</div>
|
||||
|
||||
<div class="report-section">
|
||||
<h2>📊 Consolidated Test Report</h2>
|
||||
<p>This report contains all test results from the parallel test execution, merged into one comprehensive view with screenshots embedded directly in failing test cases.</p>
|
||||
<a href="merged-report.html" class="report-link">
|
||||
📈 View Complete Test Report (All Specs)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="report-section">
|
||||
<h2>📸 Test Screenshots</h2>
|
||||
<p>Screenshots are automatically embedded within their respective failing test cases in the main report. No separate screenshot viewing required.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
window.location.href = 'merged-report.html';
|
||||
}, 3000);
|
||||
|
||||
let countdown = 3;
|
||||
const countdownEl = document.createElement('div');
|
||||
countdownEl.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #2196f3; color: white; padding: 10px 15px; border-radius: 4px; font-size: 14px;';
|
||||
countdownEl.textContent = `Auto-redirecting in ${countdown}s...`;
|
||||
document.body.appendChild(countdownEl);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown > 0) {
|
||||
countdownEl.textContent = `Auto-redirecting in ${countdown}s...`;
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
countdownEl.textContent = 'Redirecting...';
|
||||
}
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
echo "✅ Simple index page created with auto-redirect to consolidated report"
|
||||
96
cypress/scripts/generate-html-report.sh
Executable file
96
cypress/scripts/generate-html-report.sh
Executable file
@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Generating HTML report with embedded screenshots ==="
|
||||
|
||||
if [ ! -f "results/merged-report.json" ]; then
|
||||
echo "❌ No merged JSON found, cannot generate consolidated report"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Generating comprehensive HTML report from merged JSON..."
|
||||
npm run report:generate
|
||||
|
||||
if [ ! -f "results/html/merged-report.html" ]; then
|
||||
echo "❌ HTML generation failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Consolidated HTML report generated successfully"
|
||||
echo "Report size: $(wc -c < results/html/merged-report.html) bytes"
|
||||
|
||||
# Copy screenshots with proper structure for the HTML report
|
||||
echo "Copying screenshots to HTML report directory..."
|
||||
|
||||
if [ -d "screenshots" ]; then
|
||||
echo "Screenshots directory found"
|
||||
|
||||
# Remove unwanted screenshots (like before-intentional-failure)
|
||||
echo "Cleaning up unwanted screenshots..."
|
||||
find screenshots -name "*before-intentional-failure*" -type f -delete 2>/dev/null || true
|
||||
find screenshots -name "*before-*" -type f -delete 2>/dev/null || true
|
||||
|
||||
# Create screenshots directory in the HTML output
|
||||
mkdir -p "results/html/screenshots"
|
||||
|
||||
# Extract all screenshot paths expected by the HTML report and copy them accordingly
|
||||
echo "Extracting screenshot paths from HTML report..."
|
||||
|
||||
# Create screenshots directory in the HTML output
|
||||
mkdir -p "results/html/screenshots"
|
||||
|
||||
if [ -f "results/merged-report.json" ]; then
|
||||
echo "Reading expected screenshot paths from JSON report..."
|
||||
|
||||
# Extract all screenshot paths referenced in the JSON report (from context fields)
|
||||
grep -o 'screenshots/[^"]*\.png' results/merged-report.json | sort -u | while read expected_path; do
|
||||
# Extract components from expected path: screenshots/parent-dir/test-file/filename.png
|
||||
if [[ "$expected_path" =~ screenshots/([^/]+)/([^/]+)/(.+) ]]; then
|
||||
parent_dir="${BASH_REMATCH[1]}"
|
||||
test_file="${BASH_REMATCH[2]}"
|
||||
filename="${BASH_REMATCH[3]}"
|
||||
|
||||
# Try to find the actual screenshot in various possible locations
|
||||
actual_screenshot=""
|
||||
|
||||
# 1. Try full structure first: screenshots/parent-dir/test-file/filename.png
|
||||
if [ -f "screenshots/$parent_dir/$test_file/$filename" ]; then
|
||||
actual_screenshot="screenshots/$parent_dir/$test_file/$filename"
|
||||
# 2. Try flat structure: screenshots/test-file/filename.png
|
||||
elif [ -f "screenshots/$test_file/$filename" ]; then
|
||||
actual_screenshot="screenshots/$test_file/$filename"
|
||||
# 3. Try direct file: screenshots/filename.png
|
||||
elif [ -f "screenshots/$filename" ]; then
|
||||
actual_screenshot="screenshots/$filename"
|
||||
fi
|
||||
|
||||
if [ -n "$actual_screenshot" ] && [ -f "$actual_screenshot" ]; then
|
||||
# Create the expected directory structure in results/html
|
||||
target_path="results/html/$expected_path"
|
||||
target_dir=$(dirname "$target_path")
|
||||
mkdir -p "$target_dir"
|
||||
|
||||
# Copy the screenshot to the expected location
|
||||
cp "$actual_screenshot" "$target_path"
|
||||
echo "Mapped screenshot: $(basename "$test_file") -> $parent_dir/$test_file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "❌ No JSON report found, cannot determine expected screenshot paths"
|
||||
# Fallback: copy whatever structure exists
|
||||
if [ -d "screenshots" ] && [ "$(find screenshots -name '*.png' | wc -l)" -gt 0 ]; then
|
||||
echo "Fallback: copying existing screenshot structure..."
|
||||
cp -r screenshots/* results/html/screenshots/ 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Screenshots copied successfully"
|
||||
echo "Final screenshot structure:"
|
||||
find results/html/screenshots -type f -name "*.png" | head -10
|
||||
else
|
||||
echo "⚠️ No screenshots directory found"
|
||||
fi
|
||||
|
||||
echo "=== Final consolidated report ready ==="
|
||||
ls -la results/html/
|
||||
35
cypress/scripts/merge-reports.sh
Executable file
35
cypress/scripts/merge-reports.sh
Executable file
@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Merging all JSON reports into one consolidated report ==="
|
||||
|
||||
json_count=$(find results/ -name "*.json" -type f 2>/dev/null | wc -l)
|
||||
echo "Found $json_count JSON report files from parallel test execution"
|
||||
|
||||
if [ "$json_count" -gt 0 ]; then
|
||||
echo "=== JSON files found ==="
|
||||
find results/ -name "*.json" -type f | sort
|
||||
|
||||
echo "=== Merging all reports into one ==="
|
||||
npm run report:merge
|
||||
|
||||
if [ ! -f "results/merged-report.json" ]; then
|
||||
echo "❌ Merge failed - no merged-report.json created"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Successfully merged $json_count JSON reports into one"
|
||||
echo "Merged report size: $(wc -c < results/merged-report.json) bytes"
|
||||
|
||||
report=$(cat results/merged-report.json)
|
||||
echo "Consolidated report stats:"
|
||||
echo " - Total tests: $(echo "$report" | node -pe 'JSON.parse(require("fs").readFileSync(0)).stats?.tests || 0')"
|
||||
echo " - Passed: $(echo "$report" | node -pe 'JSON.parse(require("fs").readFileSync(0)).stats?.passes || 0')"
|
||||
echo " - Failed: $(echo "$report" | node -pe 'JSON.parse(require("fs").readFileSync(0)).stats?.failures || 0')"
|
||||
echo " - Duration: $(echo "$report" | node -pe 'JSON.parse(require("fs").readFileSync(0)).stats?.duration || 0')ms"
|
||||
else
|
||||
echo "❌ No JSON reports found to merge"
|
||||
echo "Creating empty report structure..."
|
||||
mkdir -p results
|
||||
echo '{"stats":{"tests":0,"passes":0,"failures":0},"results":[]}' > results/merged-report.json
|
||||
fi
|
||||
108
cypress/support/commands.ts
Normal file
108
cypress/support/commands.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/**
|
||||
* Clear the search input
|
||||
* @example cy.clearSearch()
|
||||
*/
|
||||
clearSearch(): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Search for a term and wait for search suggestions to appear
|
||||
* @param query - The search term to type
|
||||
* @example cy.searchFor('berlin')
|
||||
*/
|
||||
searchFor(query: string): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Wait for the map and search components to be ready
|
||||
* @example cy.waitForMapReady()
|
||||
*/
|
||||
waitForMapReady(): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Click on a map marker
|
||||
* @example cy.clickMarker() // clicks first marker
|
||||
*/
|
||||
clickMarker(): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Wait for a popup to appear on the map
|
||||
* @example cy.waitForPopup()
|
||||
*/
|
||||
waitForPopup(): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Close the currently open popup
|
||||
* @example cy.closePopup()
|
||||
*/
|
||||
closePopup(): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Toggle a layer's visibility in the layer control
|
||||
* @param layerName - Name of the layer to toggle
|
||||
* @example cy.toggleLayer('places')
|
||||
*/
|
||||
toggleLayer(layerName: string): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Open the layer control panel
|
||||
* @example cy.openLayerControl()
|
||||
*/
|
||||
openLayerControl(): Chainable<Element>
|
||||
|
||||
/**
|
||||
* Close the layer control panel
|
||||
* @example cy.closeLayerControl()
|
||||
*/
|
||||
closeLayerControl(): Chainable<Element>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add('clearSearch', () => {
|
||||
cy.get('[data-cy="search-input"]').clear()
|
||||
})
|
||||
|
||||
Cypress.Commands.add('searchFor', (query: string) => {
|
||||
cy.get('[data-cy="search-input"]').clear()
|
||||
cy.get('[data-cy="search-input"]').type(query)
|
||||
cy.get('[data-cy="search-suggestions"]', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
|
||||
Cypress.Commands.add('waitForMapReady', () => {
|
||||
cy.get('[data-cy="search-input"]', { timeout: 10000 }).should('be.visible')
|
||||
cy.get('.leaflet-container', { timeout: 10000 }).should('be.visible')
|
||||
cy.get('.leaflet-marker-icon', { timeout: 15000 }).should('have.length.at.least', 1)
|
||||
})
|
||||
|
||||
Cypress.Commands.add('clickMarker', () => {
|
||||
// For now, always use force click since markers might be clustered or outside viewport
|
||||
cy.get('.leaflet-marker-icon').first().click({ force: true })
|
||||
})
|
||||
|
||||
Cypress.Commands.add('waitForPopup', () => {
|
||||
cy.get('[data-cy="item-popup"]', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
|
||||
Cypress.Commands.add('closePopup', () => {
|
||||
cy.get('.leaflet-popup-close-button').click()
|
||||
})
|
||||
|
||||
Cypress.Commands.add('toggleLayer', (layerName: string) => {
|
||||
cy.get(`[data-cy="layer-checkbox-${layerName}"]`).click()
|
||||
})
|
||||
|
||||
Cypress.Commands.add('openLayerControl', () => {
|
||||
cy.get('[data-cy="layer-control-button"]').click()
|
||||
cy.get('[data-cy="layer-control-panel"]', { timeout: 5000 }).should('be.visible')
|
||||
})
|
||||
|
||||
Cypress.Commands.add('closeLayerControl', () => {
|
||||
cy.get('[data-cy="layer-control-close"]').click()
|
||||
})
|
||||
|
||||
export {}
|
||||
44
cypress/support/e2e.ts
Normal file
44
cypress/support/e2e.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import './commands'
|
||||
|
||||
// for screenshot embedding
|
||||
import addContext from 'mochawesome/addContext'
|
||||
|
||||
// Global exception handler
|
||||
Cypress.on('uncaught:exception', (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Uncaught exception:', err.message)
|
||||
return false
|
||||
})
|
||||
|
||||
// Add screenshots of failed tests to mochawesome report
|
||||
Cypress.on('test:after:run', (test, runnable) => {
|
||||
if (test.state === 'failed') {
|
||||
const adjustedSpecPath = Cypress.spec.relative.replace(/^e2e\//, '')
|
||||
|
||||
// Build the full test hierarchy title like Cypress does for screenshot naming
|
||||
const titles: string[] = []
|
||||
let current = runnable
|
||||
|
||||
while (current && current.parent) {
|
||||
if (current.title) {
|
||||
titles.unshift(current.title)
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
|
||||
const fullTitle = titles.join(' -- ').replace(/:/g, '')
|
||||
|
||||
const screenshot = `screenshots/${adjustedSpecPath}/${fullTitle} (failed).png`
|
||||
addContext({ test }, screenshot)
|
||||
|
||||
// Also add any retry screenshots if they exist
|
||||
const screenshot2 = `screenshots/${adjustedSpecPath}/${fullTitle} (failed) (attempt 2).png`
|
||||
const screenshot3 = `screenshots/${adjustedSpecPath}/${fullTitle} (failed) (attempt 3).png`
|
||||
|
||||
// Add retry screenshots (mochawesome will handle non-existent files gracefully)
|
||||
addContext({ test }, screenshot2)
|
||||
addContext({ test }, screenshot3)
|
||||
}
|
||||
})
|
||||
20
cypress/tsconfig.json
Normal file
20
cypress/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress", "node"],
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@ -113,6 +113,7 @@ export const SearchControl = () => {
|
||||
autoComplete='off'
|
||||
value={value}
|
||||
className='tw:input tw:input-bordered tw:h-12 tw:grow tw:shadow-xl tw:rounded-box tw:pr-12 tw:w-full'
|
||||
data-cy='search-input'
|
||||
ref={searchInput}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => {
|
||||
@ -124,6 +125,7 @@ export const SearchControl = () => {
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
className='tw:btn tw:btn-sm tw:btn-circle tw:absolute tw:right-2 tw:top-2'
|
||||
data-cy='search-clear-button'
|
||||
onClick={() => setValue('')}
|
||||
>
|
||||
✕
|
||||
@ -140,13 +142,17 @@ export const SearchControl = () => {
|
||||
value.length === 0 ? (
|
||||
''
|
||||
) : (
|
||||
<div className='tw:card tw:card-body tw:bg-base-100 tw:p-4 tw:mt-2 tw:shadow-xl tw:overflow-y-auto tw:max-h-[calc(100dvh-152px)] tw:absolute tw:z-3000 tw:w-83'>
|
||||
<div
|
||||
className='tw:card tw:card-body tw:bg-base-100 tw:p-4 tw:mt-2 tw:shadow-xl tw:overflow-y-auto tw:max-h-[calc(100dvh-152px)] tw:absolute tw:z-3000 tw:w-83'
|
||||
data-cy='search-suggestions'
|
||||
>
|
||||
{tagsResults.length > 0 && (
|
||||
<div className='tw:flex tw:flex-wrap'>
|
||||
{tagsResults.slice(0, 3).map((tag) => (
|
||||
<div
|
||||
key={tag.name}
|
||||
className='tw:rounded-2xl tw:text-white tw:p-1 tw:px-4 tw:shadow-md tw:card tw:mr-2 tw:mb-2 tw:cursor-pointer'
|
||||
data-cy='search-tag-result'
|
||||
style={{ backgroundColor: tag.color }}
|
||||
onClick={() => {
|
||||
addFilterTag(tag)
|
||||
@ -165,6 +171,7 @@ export const SearchControl = () => {
|
||||
<div
|
||||
key={item.id}
|
||||
className='tw:cursor-pointer tw:hover:font-bold tw:flex tw:flex-row'
|
||||
data-cy='search-item-result'
|
||||
onClick={() => {
|
||||
const marker = Object.entries(leafletRefs).find((r) => r[1].item === item)?.[1]
|
||||
.marker
|
||||
@ -178,7 +185,10 @@ export const SearchControl = () => {
|
||||
}}
|
||||
>
|
||||
{item.layer?.markerIcon.image ? (
|
||||
<div className='tw:w-7 tw:h-full tw:flex tw:justify-center tw:items-center'>
|
||||
<div
|
||||
className='tw:w-7 tw:h-full tw:flex tw:justify-center tw:items-center'
|
||||
data-cy='search-item-icon'
|
||||
>
|
||||
<SVG
|
||||
src={appState.assetsApi.url + item.layer.markerIcon.image}
|
||||
className='tw:text-current tw:mr-2 tw:mt-0'
|
||||
@ -191,7 +201,7 @@ export const SearchControl = () => {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='tw:w-7' />
|
||||
<div className='tw:w-7' data-cy='search-item-icon-placeholder' />
|
||||
)}
|
||||
<div>
|
||||
<div className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
||||
@ -207,6 +217,7 @@ export const SearchControl = () => {
|
||||
{Array.from(geoResults).map((geo) => (
|
||||
<div
|
||||
className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer'
|
||||
data-cy='search-geo-result'
|
||||
key={Math.random()}
|
||||
onClick={() => {
|
||||
searchInput.current?.blur()
|
||||
@ -238,12 +249,21 @@ export const SearchControl = () => {
|
||||
hide()
|
||||
}}
|
||||
>
|
||||
<MagnifyingGlassIcon className='tw:text-current tw:mr-2 tw:mt-0 tw:w-5' />
|
||||
<MagnifyingGlassIcon
|
||||
className='tw:text-current tw:mr-2 tw:mt-0 tw:w-5'
|
||||
data-cy='search-geo-icon'
|
||||
/>
|
||||
<div>
|
||||
<div className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
||||
<div
|
||||
className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'
|
||||
data-cy='search-geo-name'
|
||||
>
|
||||
{geo?.properties.name ? geo?.properties.name : value}
|
||||
</div>
|
||||
<div className='tw:text-xs tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
||||
<div
|
||||
className='tw:text-xs tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'
|
||||
data-cy='search-geo-details'
|
||||
>
|
||||
{geo?.properties?.city && `${capitalizeFirstLetter(geo?.properties?.city)}, `}{' '}
|
||||
{geo?.properties?.osm_value &&
|
||||
geo?.properties?.osm_value !== 'yes' &&
|
||||
@ -262,6 +282,7 @@ export const SearchControl = () => {
|
||||
{isGeoCoordinate(value) && (
|
||||
<div
|
||||
className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer'
|
||||
data-cy='search-coordinate-result'
|
||||
onClick={() => {
|
||||
marker(
|
||||
new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]),
|
||||
@ -285,12 +306,21 @@ export const SearchControl = () => {
|
||||
)
|
||||
}}
|
||||
>
|
||||
<FlagIcon className='tw:text-current tw:mr-2 tw:mt-0 tw:w-4' />
|
||||
<FlagIcon
|
||||
className='tw:text-current tw:mr-2 tw:mt-0 tw:w-4'
|
||||
data-cy='search-coordinate-icon'
|
||||
/>
|
||||
<div>
|
||||
<div className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
||||
<div
|
||||
className='tw:text-sm tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'
|
||||
data-cy='search-coordinate-text'
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div className='tw:text-xs tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'>
|
||||
<div
|
||||
className='tw:text-xs tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:max-w-[17rem]'
|
||||
data-cy='search-coordinate-label'
|
||||
>
|
||||
{'Coordiante'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -102,6 +102,7 @@ export function HeaderView({
|
||||
<div
|
||||
className={`${big ? 'tw:xl:text-3xl tw:text-2xl' : 'tw:text-xl'} tw:font-semibold tw:truncate`}
|
||||
title={title}
|
||||
data-cy='profile-title'
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
@ -174,10 +174,11 @@ export function ProfileView({ attestationApi }: { attestationApi?: ItemsApi<any>
|
||||
{item && (
|
||||
<MapOverlayPage
|
||||
key={item.id}
|
||||
data-cy='profile-view'
|
||||
className={`tw:p-0! tw:overflow-scroll tw:m-4! tw:md:w-[calc(50%-32px)] tw:w-[calc(100%-32px)] tw:min-w-80 tw:max-w-3xl tw:left-0! tw:sm:left-auto! tw:top-0 tw:bottom-0 tw:transition-opacity tw:duration-500 ${!selectPosition ? 'tw:opacity-100 tw:pointer-events-auto' : 'tw:opacity-0 tw:pointer-events-none'} tw:max-h-[1000px]`}
|
||||
>
|
||||
<>
|
||||
<div className={'tw:px-6 tw:pt-6'}>
|
||||
<div className={'tw:px-6 tw:pt-6'} data-cy='profile-header'>
|
||||
<HeaderView
|
||||
api={item.layer?.api}
|
||||
item={item}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user