diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..9717a049 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index aee5e21a..a1b0adc8 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ coverage .nyc_output cypress/snapshots/**/__diff_output__/ -**/.claude \ No newline at end of file +**/.claude +cypress/screenshots/** \ No newline at end of file diff --git a/Makefile b/Makefile index 33d2b1c1..b36397d8 100644 --- a/Makefile +++ b/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}' diff --git a/cypress.config.ts b/cypress.config.ts index 38bf5303..2f88f96d 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -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, diff --git a/cypress/README.md b/cypress/README.md new file mode 100644 index 00000000..9399e107 --- /dev/null +++ b/cypress/README.md @@ -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 +``` \ No newline at end of file diff --git a/cypress/e2e/login/auth.cy.ts b/cypress/e2e/login/auth.cy.ts new file mode 100644 index 00000000..9e396cb5 --- /dev/null +++ b/cypress/e2e/login/auth.cy.ts @@ -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); + }); + + + }); + +}); \ No newline at end of file diff --git a/cypress/support/auth-utils.ts b/cypress/support/auth-utils.ts new file mode 100644 index 00000000..ac9de9c3 --- /dev/null +++ b/cypress/support/auth-utils.ts @@ -0,0 +1,300 @@ +/// + +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) { + // 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 { + 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 { + 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 { + 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(/]*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 { + 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 { + 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; + generateSignInUrl(email: string): Chainable; + simulateAuthentication(options?: { email?: string; userId?: string; expiresIn?: number }): Chainable; + } + } +} + +// 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)); + }); +}); \ No newline at end of file diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 1b5199b0..324d0248 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,4 +1,8 @@ /// + +// 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 diff --git a/package.json b/package.json index 5b52ae19..06d86a75 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7191076f..afe6a9b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/wait-for-frontend.js b/scripts/wait-for-frontend.js new file mode 100644 index 00000000..0d8dd410 --- /dev/null +++ b/scripts/wait-for-frontend.js @@ -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(); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index bce3fc51..1fe6bb9f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {