test: implement e2e test

This commit is contained in:
Nathan
2025-08-14 09:46:22 +08:00
parent 97a60aec9c
commit d44456a265
12 changed files with 1024 additions and 23 deletions

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

@@ -33,4 +33,5 @@ coverage
.nyc_output
cypress/snapshots/**/__diff_output__/
**/.claude
**/.claude
cypress/screenshots/**

View File

@@ -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}'

View File

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

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

View 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., &amp; -> &)
const decodedUrl = signInUrl.replace(/&amp;/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));
});
});

View File

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

View File

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

@@ -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:

View 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();

View File

@@ -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: {