mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-29 19:08:33 +08:00
test: implement e2e test
This commit is contained in:
250
.github/workflows/integration-test.yml
vendored
Normal file
250
.github/workflows/integration-test.yml
vendored
Normal file
@@ -0,0 +1,250 @@
|
||||
name: Integration Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CLOUD_VERSION: latest-amd64
|
||||
|
||||
jobs:
|
||||
integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10.9.0
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clean and Run Docker-Compose
|
||||
run: |
|
||||
echo "Stopping existing compose services..."
|
||||
docker compose down -v --remove-orphans || true
|
||||
|
||||
echo "Removing project-specific resources..."
|
||||
docker compose rm -f -s -v || true
|
||||
|
||||
echo "Cleaning up unused Docker resources..."
|
||||
docker container prune -f || true
|
||||
docker image prune -f || true
|
||||
docker network prune -f || true
|
||||
docker volume prune -f || true
|
||||
|
||||
echo "Docker cleanup completed successfully"
|
||||
|
||||
- name: Checkout AppFlowy-Cloud-Premium code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: AppFlowy-IO/AppFlowy-Cloud-Premium
|
||||
path: AppFlowy-Cloud-Premium
|
||||
token: ${{ secrets.ADMIN_GITHUB_TOKEN }}
|
||||
ref: 'main'
|
||||
|
||||
- name: Run Server
|
||||
working-directory: AppFlowy-Cloud-Premium
|
||||
run: |
|
||||
cp deploy.env .env
|
||||
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
|
||||
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
|
||||
sed -i 's|APPFLOWY_SPAM_DETECT_ENABLED=.*|APPFLOWY_SPAM_DETECT_ENABLED=false|' .env
|
||||
sed -i 's|AI_OPENAI_API_KEY=.*|AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env
|
||||
|
||||
cat .env
|
||||
|
||||
export APPFLOWY_ENVIRONMENT=local
|
||||
export APPFLOWY_CLOUD_VERSION=${{ env.CLOUD_VERSION }}
|
||||
export APPFLOWY_WORKER_VERSION=${{ env.CLOUD_VERSION }}
|
||||
export APPFLOWY_ADMIN_FRONTEND_VERSION=${{ env.CLOUD_VERSION }}
|
||||
export APPFLOWY_AI_VERSION=${{ env.CLOUD_VERSION }}
|
||||
|
||||
docker login -u appflowyinc -p ${{ secrets.DOCKER_TOKEN }}
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
echo "Waiting for the container to be ready..."
|
||||
sleep 30
|
||||
docker ps -a
|
||||
|
||||
- name: Check backend health
|
||||
run: |
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if curl --fail http://localhost/health; then
|
||||
echo "Backend is healthy"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for backend... (attempt $((attempt+1))/$max_attempts)"
|
||||
sleep 5
|
||||
attempt=$((attempt+1))
|
||||
done
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "Backend failed to become healthy"
|
||||
docker compose logs
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build application
|
||||
run: pnpm run build
|
||||
|
||||
- name: Start development server
|
||||
run: |
|
||||
pnpm run dev &
|
||||
sleep 10
|
||||
curl --fail http://localhost:3000 || exit 1
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
export AF_BASE_URL=http://localhost
|
||||
export CYPRESS_BASE_URL=http://localhost:3000
|
||||
export GOTRUE_ADMIN_EMAIL=admin@example.com
|
||||
export GOTRUE_ADMIN_PASSWORD=password
|
||||
pnpm run test:integration
|
||||
env:
|
||||
AF_BASE_URL: http://localhost
|
||||
CYPRESS_BASE_URL: http://localhost:3000
|
||||
GOTRUE_ADMIN_EMAIL: admin@example.com
|
||||
GOTRUE_ADMIN_PASSWORD: password
|
||||
CI: true
|
||||
|
||||
- name: Run publish feature integration tests
|
||||
run: |
|
||||
export AF_BASE_URL=http://localhost
|
||||
export CYPRESS_BASE_URL=http://localhost:3000
|
||||
export GOTRUE_ADMIN_EMAIL=admin@example.com
|
||||
export GOTRUE_ADMIN_PASSWORD=password
|
||||
pnpm run test:integration:publish
|
||||
env:
|
||||
AF_BASE_URL: http://localhost
|
||||
CYPRESS_BASE_URL: http://localhost:3000
|
||||
GOTRUE_ADMIN_EMAIL: admin@example.com
|
||||
GOTRUE_ADMIN_PASSWORD: password
|
||||
CI: true
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-results
|
||||
path: |
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
- name: Show docker logs on failure
|
||||
if: failure()
|
||||
working-directory: AppFlowy-Cloud-Premium
|
||||
run: |
|
||||
docker compose logs
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
working-directory: AppFlowy-Cloud-Premium
|
||||
run: |
|
||||
docker compose down -v
|
||||
|
||||
publish-integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10.9.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Checkout AppFlowy-Cloud-Premium code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: AppFlowy-IO/AppFlowy-Cloud-Premium
|
||||
path: AppFlowy-Cloud-Premium
|
||||
token: ${{ secrets.ADMIN_GITHUB_TOKEN }}
|
||||
ref: 'main'
|
||||
|
||||
- name: Run Server for Publish Testing
|
||||
working-directory: AppFlowy-Cloud-Premium
|
||||
run: |
|
||||
cp deploy.env .env
|
||||
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
|
||||
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
|
||||
sed -i 's|APPFLOWY_SPAM_DETECT_ENABLED=.*|APPFLOWY_SPAM_DETECT_ENABLED=false|' .env
|
||||
sed -i 's|AI_OPENAI_API_KEY=.*|AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env
|
||||
|
||||
export APPFLOWY_ENVIRONMENT=local
|
||||
export APPFLOWY_CLOUD_VERSION=${{ env.CLOUD_VERSION }}
|
||||
export APPFLOWY_WORKER_VERSION=${{ env.CLOUD_VERSION }}
|
||||
export APPFLOWY_ADMIN_FRONTEND_VERSION=${{ env.CLOUD_VERSION }}
|
||||
export APPFLOWY_AI_VERSION=${{ env.CLOUD_VERSION }}
|
||||
|
||||
docker login -u appflowyinc -p ${{ secrets.DOCKER_TOKEN }}
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sleep 30
|
||||
|
||||
- name: Build and start application
|
||||
run: |
|
||||
pnpm run build
|
||||
pnpm run dev &
|
||||
sleep 10
|
||||
|
||||
- name: Test publish workflow
|
||||
run: |
|
||||
export AF_BASE_URL=http://localhost
|
||||
export CYPRESS_BASE_URL=http://localhost:3000
|
||||
pnpm run test:integration:publish
|
||||
env:
|
||||
AF_BASE_URL: http://localhost
|
||||
CYPRESS_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
working-directory: AppFlowy-Cloud-Premium
|
||||
run: |
|
||||
docker compose down -v
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,4 +33,5 @@ coverage
|
||||
.nyc_output
|
||||
|
||||
cypress/snapshots/**/__diff_output__/
|
||||
**/.claude
|
||||
**/.claude
|
||||
cypress/screenshots/**
|
||||
34
Makefile
34
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: image
|
||||
.PHONY: image test-integration test-integration-local test-integration-ci
|
||||
|
||||
IMAGE_NAME = appflowy-web-app
|
||||
IMAGE_TAG = latest
|
||||
@@ -12,3 +12,35 @@ image: build
|
||||
rm -rf deploy/dist
|
||||
cp -r dist deploy/
|
||||
docker build -t $(IMAGE_NAME):$(IMAGE_TAG) deploy
|
||||
|
||||
# Integration testing commands
|
||||
test-integration-local: ## Run integration tests locally with docker-compose
|
||||
@echo "Starting backend services..."
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
@echo "Waiting for backend to be ready..."
|
||||
node scripts/wait-for-backend.js http://localhost:8080 60
|
||||
@echo "Running integration tests..."
|
||||
CYPRESS_BASE_URL=http://localhost:3000 CYPRESS_BACKEND_URL=http://localhost:8080 pnpm run test:cy
|
||||
@echo "Stopping backend services..."
|
||||
docker-compose -f docker-compose.test.yml down
|
||||
|
||||
test-integration-ci: ## Run integration tests in CI environment
|
||||
@echo "Running integration tests in CI mode..."
|
||||
CYPRESS_BASE_URL=http://localhost:3000 CYPRESS_BACKEND_URL=http://localhost:8080 pnpm run test:cy
|
||||
|
||||
test-integration-publish: ## Run publish feature integration tests
|
||||
@echo "Starting backend services..."
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
@echo "Waiting for backend to be ready..."
|
||||
node scripts/wait-for-backend.js http://localhost:8080 60
|
||||
@echo "Running publish integration tests..."
|
||||
CYPRESS_BASE_URL=http://localhost:3000 CYPRESS_BACKEND_URL=http://localhost:8080 npx cypress run --spec "cypress/e2e/publish/**/*.cy.ts"
|
||||
@echo "Stopping backend services..."
|
||||
docker-compose -f docker-compose.test.yml down
|
||||
|
||||
clean-test: ## Clean up test artifacts and docker volumes
|
||||
docker-compose -f docker-compose.test.yml down -v
|
||||
rm -rf cypress/videos cypress/screenshots coverage .nyc_output
|
||||
|
||||
help: ## Show this help message
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
export default defineConfig({
|
||||
env: {
|
||||
codeCoverage: {
|
||||
exclude: ['cypress/**/*.*', '**/__tests__/**/*.*', '**/*.test.*'],
|
||||
},
|
||||
// Backend URL configuration - load from .env or use defaults
|
||||
AF_BASE_URL: process.env.AF_BASE_URL || 'http://localhost:8000',
|
||||
AF_GOTRUE_URL: process.env.AF_GOTRUE_URL || 'http://localhost:9999',
|
||||
AF_WS_URL: process.env.AF_WS_URL || 'ws://localhost:8000/ws/v1',
|
||||
AF_WS_V2_URL: process.env.AF_WS_V2_URL || 'ws://localhost:8000/ws/v2',
|
||||
GOTRUE_ADMIN_EMAIL: process.env.GOTRUE_ADMIN_EMAIL || 'admin@example.com',
|
||||
GOTRUE_ADMIN_PASSWORD: process.env.GOTRUE_ADMIN_PASSWORD || 'password',
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost:3000',
|
||||
setupNodeEvents(on, config) {
|
||||
// Override baseUrl if CYPRESS_BASE_URL is set
|
||||
if (process.env.CYPRESS_BASE_URL) {
|
||||
config.baseUrl = process.env.CYPRESS_BASE_URL;
|
||||
}
|
||||
// Pass environment variables to Cypress
|
||||
config.env.AF_BASE_URL = process.env.AF_BASE_URL || config.env.AF_BASE_URL;
|
||||
config.env.AF_GOTRUE_URL = process.env.AF_GOTRUE_URL || config.env.AF_GOTRUE_URL;
|
||||
config.env.AF_WS_URL = process.env.AF_WS_URL || config.env.AF_WS_URL;
|
||||
config.env.AF_WS_V2_URL = process.env.AF_WS_V2_URL || config.env.AF_WS_V2_URL;
|
||||
config.env.GOTRUE_ADMIN_EMAIL = process.env.GOTRUE_ADMIN_EMAIL || config.env.GOTRUE_ADMIN_EMAIL;
|
||||
config.env.GOTRUE_ADMIN_PASSWORD = process.env.GOTRUE_ADMIN_PASSWORD || config.env.GOTRUE_ADMIN_PASSWORD;
|
||||
config.env.USE_REAL_BACKEND = true;
|
||||
|
||||
// Add task for logging to Node.js console
|
||||
on('task', {
|
||||
log(message) {
|
||||
console.log(message);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
supportFile: 'cypress/support/commands.ts',
|
||||
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
|
||||
},
|
||||
watchForFileChanges: false,
|
||||
component: {
|
||||
@@ -19,9 +59,7 @@ export default defineConfig({
|
||||
},
|
||||
chromeWebSecurity: false,
|
||||
retries: {
|
||||
// Configure retry attempts for `cypress run`
|
||||
// Default is 0
|
||||
runMode: 10,
|
||||
runMode: 1,
|
||||
// Configure retry attempts for `cypress open`
|
||||
// Default is 0
|
||||
openMode: 0,
|
||||
|
||||
181
cypress/README.md
Normal file
181
cypress/README.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Cypress E2E Tests
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains end-to-end tests for the AppFlowy Web application, with a focus on the publish feature.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
cypress/
|
||||
├── e2e/
|
||||
│ └── publish/
|
||||
│ ├── publish.integration.cy.ts # Basic publish tests
|
||||
│ ├── publish.simple.cy.ts # Simple publish tests without auth
|
||||
│ └── publish-with-auth.cy.ts # Full publish tests with authentication
|
||||
├── support/
|
||||
│ ├── auth-utils.ts # Authentication utilities
|
||||
│ ├── commands.ts # Custom Cypress commands
|
||||
│ └── component.ts # Component test support
|
||||
└── fixtures/ # Test data
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The tests use GoTrue admin functionality to generate sign-in URLs for test users, similar to the Rust desktop client implementation.
|
||||
|
||||
### Auth Utilities
|
||||
|
||||
The `auth-utils.ts` file provides:
|
||||
- `AuthTestUtils` class for managing authentication
|
||||
- Custom Cypress commands: `cy.signIn()` and `cy.generateSignInUrl()`
|
||||
- Support for both real backend and mocked authentication
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Local Development (with mocked data)
|
||||
|
||||
```bash
|
||||
# Start the dev server
|
||||
pnpm run dev
|
||||
|
||||
# Run all integration tests
|
||||
pnpm run test:integration
|
||||
|
||||
# Run only publish tests
|
||||
pnpm run test:integration:publish
|
||||
|
||||
# Run specific test file
|
||||
npx cypress run --spec 'cypress/e2e/publish/publish-with-auth.cy.ts'
|
||||
|
||||
# Open Cypress UI for interactive testing
|
||||
npx cypress open
|
||||
```
|
||||
|
||||
### With Real Backend (AppFlowy-Cloud-Premium)
|
||||
|
||||
1. Start the AppFlowy-Cloud-Premium backend:
|
||||
```bash
|
||||
# Clone and setup AppFlowy-Cloud-Premium
|
||||
git clone https://github.com/AppFlowy-IO/AppFlowy-Cloud-Premium.git
|
||||
cd AppFlowy-Cloud-Premium
|
||||
|
||||
# Configure environment
|
||||
cp deploy.env .env
|
||||
# Edit .env to set GOTRUE_MAILER_AUTOCONFIRM=true
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. Run tests with real backend:
|
||||
```bash
|
||||
# Set environment variables
|
||||
export AF_BASE_URL=http://localhost
|
||||
export GOTRUE_ADMIN_EMAIL=admin@example.com
|
||||
export GOTRUE_ADMIN_PASSWORD=password
|
||||
export USE_REAL_BACKEND=true
|
||||
|
||||
# Run tests
|
||||
pnpm run test:integration:publish
|
||||
```
|
||||
|
||||
### In CI/CD
|
||||
|
||||
The GitHub Actions workflow automatically:
|
||||
1. Checks out AppFlowy-Cloud-Premium
|
||||
2. Configures and starts the backend services
|
||||
3. Runs integration tests against the real backend
|
||||
4. Uploads test results and coverage reports
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `AF_BASE_URL` | Base URL for the backend API | `http://localhost` |
|
||||
| `CYPRESS_BASE_URL` | Base URL for the web application | `http://localhost:3000` |
|
||||
| `GOTRUE_ADMIN_EMAIL` | GoTrue admin email for test user creation | `admin@example.com` |
|
||||
| `GOTRUE_ADMIN_PASSWORD` | GoTrue admin password | `password` |
|
||||
| `CI` | Set to `true` in CI environment | `false` |
|
||||
| `USE_REAL_BACKEND` | Use real backend instead of mocks | `false` |
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```typescript
|
||||
describe('Feature Name', () => {
|
||||
before(() => {
|
||||
// Sign in if needed
|
||||
if (Cypress.env('CI') || Cypress.env('USE_REAL_BACKEND')) {
|
||||
cy.signIn('test@example.com');
|
||||
}
|
||||
});
|
||||
|
||||
it('should do something', () => {
|
||||
cy.visit('/page');
|
||||
cy.get('[data-testid="element"]').click();
|
||||
cy.contains('Expected text').should('be.visible');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Using Authentication
|
||||
|
||||
```typescript
|
||||
// Sign in a test user
|
||||
cy.signIn('test@example.com');
|
||||
|
||||
// Generate sign-in URL without signing in
|
||||
cy.generateSignInUrl('test@example.com').then((url) => {
|
||||
// Use the URL as needed
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking API Responses
|
||||
|
||||
```typescript
|
||||
cy.intercept('GET', '**/api/workspace/*/publish/*', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
// Mock response data
|
||||
}
|
||||
}).as('getPublishData');
|
||||
|
||||
cy.visit('/namespace/document');
|
||||
cy.wait('@getPublishData');
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use data-testid attributes**: Add `data-testid` attributes to elements for reliable selection
|
||||
2. **Mock external dependencies**: Use `cy.intercept()` to mock API calls when not using real backend
|
||||
3. **Clean up state**: Clear localStorage/sessionStorage between tests when needed
|
||||
4. **Use proper waits**: Use `cy.wait()` with aliases instead of arbitrary timeouts
|
||||
5. **Test both success and error cases**: Include tests for error handling and edge cases
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests timeout waiting for elements
|
||||
- Check if the dev server is running (`pnpm run dev`)
|
||||
- Verify selectors are correct (use Cypress UI to inspect)
|
||||
- Increase timeout: `cy.get('[data-testid="element"]', { timeout: 10000 })`
|
||||
|
||||
### Authentication fails
|
||||
- Verify backend is running and healthy
|
||||
- Check environment variables are set correctly
|
||||
- Ensure GoTrue admin credentials are correct
|
||||
|
||||
### Tests fail in CI but pass locally
|
||||
- Check CI environment variables
|
||||
- Verify backend services are fully started before tests run
|
||||
- Review Docker logs for any startup issues
|
||||
|
||||
## Coverage
|
||||
|
||||
Test coverage reports are generated in the `coverage/` directory after running tests with coverage enabled:
|
||||
|
||||
```bash
|
||||
pnpm run test:unit:coverage
|
||||
pnpm run test:components:coverage
|
||||
```
|
||||
137
cypress/e2e/login/auth.cy.ts
Normal file
137
cypress/e2e/login/auth.cy.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AuthTestUtils } from '../../support/auth-utils';
|
||||
|
||||
describe('Login Feature Tests with Authentication', () => {
|
||||
const AF_BASE_URL = Cypress.env('AF_BASE_URL');
|
||||
const AF_GOTRUE_URL = Cypress.env('AF_GOTRUE_URL');
|
||||
const generateRandomEmail = () => `${uuidv4()}@appflowy.io`;
|
||||
|
||||
before(() => {
|
||||
// Log environment configuration for debugging
|
||||
cy.task('log', `Test Environment Configuration:
|
||||
- AF_BASE_URL: ${AF_BASE_URL}
|
||||
- AF_GOTRUE_URL: ${AF_GOTRUE_URL}
|
||||
- Running in CI: ${Cypress.env('CI')}
|
||||
- Use Real Backend: ${Cypress.env('USE_REAL_BACKEND')}`);
|
||||
|
||||
});
|
||||
|
||||
describe('LoginAuth Component Tests', () => {
|
||||
it('should generate and exchange tokens for authentication', () => {
|
||||
const randomEmail = generateRandomEmail();
|
||||
const authUtils = new AuthTestUtils();
|
||||
|
||||
cy.task('log', `Testing token generation and exchange for: ${randomEmail}`);
|
||||
|
||||
// Generate sign-in URL and verify token exchange
|
||||
authUtils.generateSignInUrl(randomEmail).then((signInUrl) => {
|
||||
expect(signInUrl).to.include('#access_token=');
|
||||
expect(signInUrl).to.include('refresh_token=');
|
||||
|
||||
cy.task('log', `Generated sign-in URL with tokens for ${randomEmail}`);
|
||||
|
||||
// Extract tokens from the URL
|
||||
const fragment = signInUrl.split('#')[1];
|
||||
const params = new URLSearchParams(fragment);
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const accessToken = params.get('access_token');
|
||||
|
||||
expect(refreshToken).to.exist;
|
||||
expect(accessToken).to.exist;
|
||||
|
||||
// Test the refresh token exchange
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `${AF_GOTRUE_URL}/token?grant_type=refresh_token`,
|
||||
body: {
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((response) => {
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.body.access_token).to.exist;
|
||||
expect(response.body.refresh_token).to.exist;
|
||||
expect(response.body.user).to.exist;
|
||||
|
||||
cy.task('log', `Successfully exchanged refresh token for user: ${response.body.user.email}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show AppFlowy Web login page and authenticate', () => {
|
||||
// Handle uncaught exceptions during workspace creation
|
||||
cy.on('uncaught:exception', (err, runnable) => {
|
||||
// Ignore "No workspace or service found" error which happens before workspace is created
|
||||
if (err.message.includes('No workspace or service found')) {
|
||||
return false;
|
||||
}
|
||||
// Let other errors fail the test
|
||||
return true;
|
||||
});
|
||||
|
||||
// Visit the AppFlowy Web login page
|
||||
cy.visit('/login', { failOnStatusCode: false });
|
||||
|
||||
// Wait for the page to load
|
||||
cy.wait(2000);
|
||||
|
||||
// Check if we're on the login page or if login UI is visible
|
||||
cy.url().then((url) => {
|
||||
cy.task('log', `Current URL: ${url}`);
|
||||
|
||||
// Look for common login elements
|
||||
// This might include login buttons, email inputs, etc.
|
||||
cy.get('body').then(($body) => {
|
||||
// Log what we see on the page
|
||||
cy.task('log', `Page title: ${$body.find('title').text() || document.title}`);
|
||||
|
||||
// Try to find login-related elements
|
||||
const hasLoginButton = $body.find('button:contains("Login"), button:contains("Sign in"), button:contains("Sign In")').length > 0;
|
||||
const hasEmailInput = $body.find('input[type="email"], input[name="email"]').length > 0 ||
|
||||
$body.find('input').filter(function () {
|
||||
return $(this).attr('placeholder')?.toLowerCase().includes('email');
|
||||
}).length > 0;
|
||||
|
||||
if (hasLoginButton || hasEmailInput) {
|
||||
cy.task('log', 'Login page elements found');
|
||||
} else {
|
||||
cy.task('log', 'No obvious login elements found - checking for other auth indicators');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Now test the authentication flow using signInWithTestUrl
|
||||
const randomEmail = generateRandomEmail();
|
||||
const authUtils = new AuthTestUtils();
|
||||
|
||||
cy.task('log', `Testing authentication for: ${randomEmail}`);
|
||||
|
||||
// Use the signInWithTestUrl method which handles the complete flow
|
||||
authUtils.signInWithTestUrl(randomEmail).then(() => {
|
||||
// Verify authentication was successful
|
||||
cy.window().then((win) => {
|
||||
const token = win.localStorage.getItem('token');
|
||||
expect(token).to.exist;
|
||||
|
||||
const tokenData = JSON.parse(token!);
|
||||
expect(tokenData.access_token).to.exist;
|
||||
expect(tokenData.refresh_token).to.exist;
|
||||
expect(tokenData.user).to.exist;
|
||||
|
||||
cy.task('log', `Successfully authenticated user: ${tokenData.user.email}`);
|
||||
});
|
||||
|
||||
// Verify we're on the app page
|
||||
cy.url().should('include', '/app');
|
||||
|
||||
cy.task('log', 'Authentication flow completed successfully');
|
||||
});
|
||||
cy.wait(2000);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
300
cypress/support/auth-utils.ts
Normal file
300
cypress/support/auth-utils.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
export interface AuthConfig {
|
||||
baseUrl: string;
|
||||
gotrueUrl: string;
|
||||
adminEmail: string;
|
||||
adminPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* E2E test utility for authentication with GoTrue admin
|
||||
*/
|
||||
export class AuthTestUtils {
|
||||
private config: AuthConfig;
|
||||
private adminAccessToken?: string;
|
||||
|
||||
constructor(config?: Partial<AuthConfig>) {
|
||||
// Use AF_GOTRUE_URL from environment if available, otherwise construct from AF_BASE_URL
|
||||
const baseUrl = config?.baseUrl || Cypress.env('AF_BASE_URL') || 'http://localhost:8000';
|
||||
const gotrueUrl = config?.gotrueUrl || Cypress.env('AF_GOTRUE_URL') || `http://localhost:9999`;
|
||||
|
||||
this.config = {
|
||||
baseUrl: baseUrl,
|
||||
gotrueUrl: gotrueUrl,
|
||||
adminEmail: config?.adminEmail || Cypress.env('GOTRUE_ADMIN_EMAIL') || 'admin@example.com',
|
||||
adminPassword: config?.adminPassword || Cypress.env('GOTRUE_ADMIN_PASSWORD') || 'password',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in as admin user to get access token
|
||||
*/
|
||||
signInAsAdmin(): Cypress.Chainable<string> {
|
||||
if (this.adminAccessToken) {
|
||||
return cy.wrap(this.adminAccessToken);
|
||||
}
|
||||
|
||||
// Try to sign in with existing admin account
|
||||
const url = `${this.config.gotrueUrl}/token?grant_type=password`;
|
||||
|
||||
return cy.request({
|
||||
method: 'POST',
|
||||
url: url,
|
||||
body: {
|
||||
email: this.config.adminEmail,
|
||||
password: this.config.adminPassword,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
this.adminAccessToken = response.body.access_token;
|
||||
return this.adminAccessToken;
|
||||
} else {
|
||||
throw new Error(`Failed to sign in as admin: ${response.status} - ${JSON.stringify(response.body)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sign-in action link for a specific email
|
||||
* Similar to admin_generate_link in Rust
|
||||
*/
|
||||
generateSignInActionLink(email: string): Cypress.Chainable<string> {
|
||||
return this.signInAsAdmin().then((adminToken) => {
|
||||
return cy.request({
|
||||
method: 'POST',
|
||||
url: `${this.config.gotrueUrl}/admin/generate_link`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
email: email,
|
||||
type: 'magiclink',
|
||||
redirect_to: Cypress.config('baseUrl'),
|
||||
},
|
||||
}).then((response) => {
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to generate action link: ${response.status}`);
|
||||
}
|
||||
return response.body.action_link;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sign-in URL from action link HTML
|
||||
* Similar to extract_sign_in_url in Rust
|
||||
*/
|
||||
extractSignInUrl(actionLink: string): Cypress.Chainable<string> {
|
||||
return cy.request({
|
||||
method: 'GET',
|
||||
url: actionLink,
|
||||
followRedirect: false, // Don't follow redirects automatically
|
||||
failOnStatusCode: false, // Don't fail on non-2xx status
|
||||
}).then((response) => {
|
||||
// Check if we got a redirect (3xx status)
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers['location'] || response.headers['Location'];
|
||||
if (location) {
|
||||
// The redirect location contains the sign-in URL with tokens
|
||||
// It's in the format: http://localhost:9999/appflowy_web://#access_token=...
|
||||
// We need to extract the part after the host (appflowy_web://...)
|
||||
|
||||
// Parse the redirect URL
|
||||
const redirectUrl = new URL(location, actionLink);
|
||||
// The path contains the actual redirect URL (appflowy_web://)
|
||||
const pathWithoutSlash = redirectUrl.pathname.substring(1); // Remove leading /
|
||||
const signInUrl = pathWithoutSlash + redirectUrl.hash;
|
||||
|
||||
return signInUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// If no redirect, try to parse HTML for an anchor tag
|
||||
const html = response.body;
|
||||
|
||||
// Use regex to extract href from the first anchor tag
|
||||
const hrefMatch = html.match(/<a[^>]*href=["']([^"']+)["']/);
|
||||
|
||||
if (!hrefMatch || !hrefMatch[1]) {
|
||||
throw new Error('Could not extract sign-in URL from action link');
|
||||
}
|
||||
|
||||
const signInUrl = hrefMatch[1];
|
||||
|
||||
// Decode HTML entities if present (e.g., & -> &)
|
||||
const decodedUrl = signInUrl.replace(/&/g, '&');
|
||||
|
||||
return decodedUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete sign-in URL for a user email
|
||||
* Combines generateSignInActionLink and extractSignInUrl
|
||||
*/
|
||||
generateSignInUrl(email: string): Cypress.Chainable<string> {
|
||||
return this.generateSignInActionLink(email).then((actionLink) => {
|
||||
return this.extractSignInUrl(actionLink);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in a user using the generated sign-in URL
|
||||
* Replicates the logic from APIService.signInWithUrl
|
||||
*/
|
||||
signInWithTestUrl(email: string): Cypress.Chainable<void> {
|
||||
return this.generateSignInUrl(email).then((callbackLink) => {
|
||||
// Replicate signInWithUrl logic from http_api.ts
|
||||
// Extract hash from the callback link
|
||||
const hashIndex = callbackLink.indexOf('#');
|
||||
if (hashIndex === -1) {
|
||||
throw new Error('No hash found in callback link');
|
||||
}
|
||||
|
||||
const hash = callbackLink.substring(hashIndex);
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error('No access token or refresh token found');
|
||||
}
|
||||
|
||||
// First verify the token (matching verifyToken from http_api.ts)
|
||||
return cy.request({
|
||||
method: 'GET',
|
||||
url: `${this.config.baseUrl}/api/user/verify/${accessToken}`,
|
||||
failOnStatusCode: false,
|
||||
}).then((verifyResponse) => {
|
||||
cy.task('log', `Token verification response: ${JSON.stringify(verifyResponse)}`);
|
||||
|
||||
if (verifyResponse.status !== 200) {
|
||||
throw new Error('Token verification failed');
|
||||
}
|
||||
|
||||
// Then refresh the token (matching refreshToken from gotrue.ts)
|
||||
return cy.request({
|
||||
method: 'POST',
|
||||
url: `${this.config.gotrueUrl}/token?grant_type=refresh_token`,
|
||||
body: {
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to refresh token: ${response.status}`);
|
||||
}
|
||||
|
||||
// Store the token in localStorage
|
||||
const tokenData = response.body;
|
||||
|
||||
return cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', JSON.stringify(tokenData));
|
||||
|
||||
// Now visit the app - we should be authenticated
|
||||
cy.visit('/app');
|
||||
|
||||
// Wait for the app to load and workspace to be created
|
||||
cy.wait(2000);
|
||||
|
||||
// Reload the page to ensure workspace is loaded after verification
|
||||
cy.reload();
|
||||
|
||||
// Wait for the page to load after reload
|
||||
cy.wait(2000);
|
||||
|
||||
// Verify we're logged in and on the app page
|
||||
cy.url().should('not.include', '/login');
|
||||
cy.url().should('include', '/app');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cypress command to sign in a test user
|
||||
*/
|
||||
export function signInTestUser(email: string = 'test@example.com'): Cypress.Chainable {
|
||||
const authUtils = new AuthTestUtils();
|
||||
return authUtils.signInWithTestUrl(email);
|
||||
}
|
||||
|
||||
// Add custom Cypress commands
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
signIn(email?: string): Chainable<void>;
|
||||
generateSignInUrl(email: string): Chainable<string>;
|
||||
simulateAuthentication(options?: { email?: string; userId?: string; expiresIn?: number }): Chainable<void>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the commands
|
||||
Cypress.Commands.add('signIn', (email: string = 'test@example.com') => {
|
||||
const authUtils = new AuthTestUtils();
|
||||
return authUtils.signInWithTestUrl(email);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('generateSignInUrl', (email: string) => {
|
||||
const authUtils = new AuthTestUtils();
|
||||
return authUtils.generateSignInUrl(email);
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulate an authenticated user by setting a valid token in localStorage.
|
||||
* This is useful for E2E tests that need to bypass the login flow.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.email - User email (defaults to random UUID@appflowy.io)
|
||||
* @param options.userId - User ID (defaults to random UUID)
|
||||
* @param options.expiresIn - Token expiration in seconds from now (defaults to 3600)
|
||||
*
|
||||
* @example
|
||||
* // Use with default values
|
||||
* cy.simulateAuthentication();
|
||||
*
|
||||
* @example
|
||||
* // Use with custom email
|
||||
* cy.simulateAuthentication({ email: 'test@appflowy.io' });
|
||||
*
|
||||
* @example
|
||||
* // Use in beforeEach hook for all tests
|
||||
* beforeEach(() => {
|
||||
* cy.simulateAuthentication();
|
||||
* cy.visit('/app');
|
||||
* });
|
||||
*/
|
||||
Cypress.Commands.add('simulateAuthentication', (options = {}) => {
|
||||
const {
|
||||
email = `${Math.random().toString(36).substring(7)}@appflowy.io`,
|
||||
userId = `user-${Math.random().toString(36).substring(7)}`,
|
||||
expiresIn = 3600
|
||||
} = options;
|
||||
|
||||
const mockTokenData = {
|
||||
access_token: `mock-token-${Math.random().toString(36).substring(7)}`,
|
||||
refresh_token: 'mock-refresh-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
|
||||
user: {
|
||||
id: userId,
|
||||
email: email,
|
||||
name: 'Test User'
|
||||
}
|
||||
};
|
||||
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', JSON.stringify(mockTokenData));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
// Import auth utilities
|
||||
import './auth-utils';
|
||||
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
@@ -8,23 +12,6 @@
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
|
||||
Cypress.Commands.add('mockAPI', () => {
|
||||
// Mock the API
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true && vite",
|
||||
"dev:coverage": "cross-env COVERAGE=true vite",
|
||||
"build": "vite build",
|
||||
"type-check": "tsc --noEmit --project tsconfig.web.json",
|
||||
@@ -20,6 +20,10 @@
|
||||
"test:unit:coverage": "cross-env COVERAGE=true jest --coverage",
|
||||
"test:components:coverage": "cross-env COVERAGE=true cypress run --component --browser chrome --headless",
|
||||
"test:cy": "cypress run",
|
||||
"test:integration": "cypress run --spec 'cypress/e2e/**/*.cy.ts'",
|
||||
"test:integration:login": "CYPRESS_BASE_URL=http://localhost:3000 node scripts/wait-for-frontend.js && CYPRESS_BASE_URL=http://localhost:3000 cypress run --spec 'cypress/e2e/login/**/*.cy.ts' --headed --browser chrome",
|
||||
"test:integration:publish": "cypress run --spec 'cypress/e2e/publish/**/*.cy.ts'",
|
||||
"wait:backend": "AF_BASE_URL=${AF_BASE_URL:-http://localhost} node scripts/wait-for-backend.js",
|
||||
"coverage": "cross-env COVERAGE=true pnpm run test:unit && cross-env COVERAGE=true pnpm run test:components",
|
||||
"generate-tokens": "node scripts/system-token/convert-tokens.cjs",
|
||||
"generate-protobuf": "pbjs -t static-module -w es6 -o ./src/proto/messages.js ./src/proto/messages.proto & pbts -o ./src/proto/messages.d.ts ./src/proto/messages.js"
|
||||
@@ -202,6 +206,7 @@
|
||||
"cypress": "^13.7.2",
|
||||
"cypress-image-snapshot": "^4.0.1",
|
||||
"cypress-real-events": "^1.13.0",
|
||||
"dotenv": "^17.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -534,6 +534,9 @@ importers:
|
||||
cypress-real-events:
|
||||
specifier: ^1.13.0
|
||||
version: 1.14.0(cypress@13.17.0)
|
||||
dotenv:
|
||||
specifier: ^17.2.1
|
||||
version: 17.2.1
|
||||
eslint:
|
||||
specifier: ^8.57.0
|
||||
version: 8.57.1
|
||||
@@ -4927,6 +4930,10 @@ packages:
|
||||
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@17.2.1:
|
||||
resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
downloadjs@1.4.7:
|
||||
resolution: {integrity: sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==}
|
||||
|
||||
@@ -13930,6 +13937,8 @@ snapshots:
|
||||
|
||||
dotenv@16.5.0: {}
|
||||
|
||||
dotenv@17.2.1: {}
|
||||
|
||||
downloadjs@1.4.7: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
|
||||
60
scripts/wait-for-frontend.js
Normal file
60
scripts/wait-for-frontend.js
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import http from 'http';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const FRONTEND_URL = process.env.CYPRESS_BASE_URL || 'http://localhost:3000';
|
||||
const MAX_RETRIES = 30;
|
||||
const RETRY_DELAY = 2000;
|
||||
|
||||
console.log(chalk.blue(`🔍 Waiting for frontend at ${FRONTEND_URL}...`));
|
||||
|
||||
let retries = 0;
|
||||
|
||||
function checkFrontend() {
|
||||
const url = new URL(FRONTEND_URL);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 5000
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (res.statusCode && res.statusCode < 500) {
|
||||
console.log(chalk.green(`✅ Frontend is ready at ${FRONTEND_URL}`));
|
||||
process.exit(0);
|
||||
} else {
|
||||
retry(`Frontend returned status ${res.statusCode}`);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
retry(`Connection failed: ${err.message}`);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
retry('Request timeout');
|
||||
});
|
||||
|
||||
req.end();
|
||||
}
|
||||
|
||||
function retry(reason) {
|
||||
retries++;
|
||||
|
||||
if (retries >= MAX_RETRIES) {
|
||||
console.error(chalk.red(`❌ Frontend not available after ${MAX_RETRIES} attempts`));
|
||||
console.error(chalk.red(` Last error: ${reason}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(` Attempt ${retries}/${MAX_RETRIES}: ${reason}. Retrying in ${RETRY_DELAY / 1000}s...`));
|
||||
setTimeout(checkFrontend, RETRY_DELAY);
|
||||
}
|
||||
|
||||
// Start checking
|
||||
checkFrontend();
|
||||
@@ -99,6 +99,7 @@ export default defineConfig({
|
||||
// prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
server: {
|
||||
host: '0.0.0.0', // Listen on all network interfaces (both IPv4 and IPv6)
|
||||
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
|
||||
Reference in New Issue
Block a user