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:
mahula 2025-10-09 21:59:06 +02:00 committed by GitHub
parent f80357e45f
commit 78a8c68800
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 8042 additions and 17 deletions

211
.github/workflows/test.e2e.yml vendored Normal file
View 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
View 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
View File

@ -1,2 +1,6 @@
.claude/ .claude/
data/ data/
cypress/node_modules/
cypress/results/
cypress/runner-results/
cypress/screenshots/

View File

@ -37,11 +37,6 @@ npm run test:unit:dev # Run Vitest in watch mode
npm run docs:generate # Generate TypeDoc documentation 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) ### Backend (Directus)
```bash ```bash
cd app cd app
@ -94,9 +89,9 @@ npx directus-sync push --directus-url http://localhost:8055 --directus-email adm
### Testing Strategy ### Testing Strategy
- **Unit Tests**: Vitest for lib components with coverage reporting - **Unit Tests**: Vitest for lib components with coverage reporting
- **Component Tests**: Cypress for React component integration
- **Linting**: ESLint with TypeScript rules for code quality - **Linting**: ESLint with TypeScript rules for code quality
- **Type Checking**: TypeScript strict mode across all packages - **Type Checking**: TypeScript strict mode across all packages
- **End-to-End Tests**: Cypress for testing the app's UI and user flows
### Import Conventions ### Import Conventions

View File

@ -3,7 +3,7 @@ RUN apk add python3 g++ make
WORKDIR /extensions WORKDIR /extensions
ADD extensions . ADD extensions .
RUN npm install 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 mkdir -p ./directus
RUN cd node_modules && find . -maxdepth 1 -type d -name "directus-extension-*" -exec mv {} ../directus \; RUN cd node_modules && find . -maxdepth 1 -type d -name "directus-extension-*" -exec mv {} ../directus \;

View File

@ -51,6 +51,133 @@
] ]
}, },
"layer" : "layer-people" "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
View 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
View 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
},
},
})

View 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 + '/')
})
})

View 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?')
})
})

View 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
View 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

File diff suppressed because it is too large Load Diff

37
cypress/package.json Normal file
View 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"
}
}

View 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
}

View 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"

View 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/

View 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
View 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
View 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
View 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"
]
}

View File

@ -113,6 +113,7 @@ export const SearchControl = () => {
autoComplete='off' autoComplete='off'
value={value} 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' 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} ref={searchInput}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onFocus={() => { onFocus={() => {
@ -124,6 +125,7 @@ export const SearchControl = () => {
{value.length > 0 && ( {value.length > 0 && (
<button <button
className='tw:btn tw:btn-sm tw:btn-circle tw:absolute tw:right-2 tw:top-2' className='tw:btn tw:btn-sm tw:btn-circle tw:absolute tw:right-2 tw:top-2'
data-cy='search-clear-button'
onClick={() => setValue('')} onClick={() => setValue('')}
> >
@ -140,13 +142,17 @@ export const SearchControl = () => {
value.length === 0 ? ( 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 && ( {tagsResults.length > 0 && (
<div className='tw:flex tw:flex-wrap'> <div className='tw:flex tw:flex-wrap'>
{tagsResults.slice(0, 3).map((tag) => ( {tagsResults.slice(0, 3).map((tag) => (
<div <div
key={tag.name} 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' 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 }} style={{ backgroundColor: tag.color }}
onClick={() => { onClick={() => {
addFilterTag(tag) addFilterTag(tag)
@ -165,6 +171,7 @@ export const SearchControl = () => {
<div <div
key={item.id} key={item.id}
className='tw:cursor-pointer tw:hover:font-bold tw:flex tw:flex-row' className='tw:cursor-pointer tw:hover:font-bold tw:flex tw:flex-row'
data-cy='search-item-result'
onClick={() => { onClick={() => {
const marker = Object.entries(leafletRefs).find((r) => r[1].item === item)?.[1] const marker = Object.entries(leafletRefs).find((r) => r[1].item === item)?.[1]
.marker .marker
@ -178,7 +185,10 @@ export const SearchControl = () => {
}} }}
> >
{item.layer?.markerIcon.image ? ( {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 <SVG
src={appState.assetsApi.url + item.layer.markerIcon.image} src={appState.assetsApi.url + item.layer.markerIcon.image}
className='tw:text-current tw:mr-2 tw:mt-0' className='tw:text-current tw:mr-2 tw:mt-0'
@ -191,7 +201,7 @@ export const SearchControl = () => {
/> />
</div> </div>
) : ( ) : (
<div className='tw:w-7' /> <div className='tw:w-7' data-cy='search-item-icon-placeholder' />
)} )}
<div> <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]'>
@ -207,6 +217,7 @@ export const SearchControl = () => {
{Array.from(geoResults).map((geo) => ( {Array.from(geoResults).map((geo) => (
<div <div
className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer' className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer'
data-cy='search-geo-result'
key={Math.random()} key={Math.random()}
onClick={() => { onClick={() => {
searchInput.current?.blur() searchInput.current?.blur()
@ -238,12 +249,21 @@ export const SearchControl = () => {
hide() 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>
<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} {geo?.properties.name ? geo?.properties.name : value}
</div> </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?.city && `${capitalizeFirstLetter(geo?.properties?.city)}, `}{' '}
{geo?.properties?.osm_value && {geo?.properties?.osm_value &&
geo?.properties?.osm_value !== 'yes' && geo?.properties?.osm_value !== 'yes' &&
@ -262,6 +282,7 @@ export const SearchControl = () => {
{isGeoCoordinate(value) && ( {isGeoCoordinate(value) && (
<div <div
className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer' className='tw:flex tw:flex-row tw:hover:font-bold tw:cursor-pointer'
data-cy='search-coordinate-result'
onClick={() => { onClick={() => {
marker( marker(
new LatLng(extractCoordinates(value)![0], extractCoordinates(value)![1]), 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>
<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} {value}
</div> </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'} {'Coordiante'}
</div> </div>
</div> </div>

View File

@ -102,6 +102,7 @@ export function HeaderView({
<div <div
className={`${big ? 'tw:xl:text-3xl tw:text-2xl' : 'tw:text-xl'} tw:font-semibold tw:truncate`} className={`${big ? 'tw:xl:text-3xl tw:text-2xl' : 'tw:text-xl'} tw:font-semibold tw:truncate`}
title={title} title={title}
data-cy='profile-title'
> >
{title} {title}
</div> </div>

View File

@ -174,10 +174,11 @@ export function ProfileView({ attestationApi }: { attestationApi?: ItemsApi<any>
{item && ( {item && (
<MapOverlayPage <MapOverlayPage
key={item.id} 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]`} 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 <HeaderView
api={item.layer?.api} api={item.layer?.api}
item={item} item={item}