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