Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
493 changes: 493 additions & 0 deletions docs/database-deletion-from-sidebar.md

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions playwright/e2e/support/auth-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Page } from '@playwright/test';

export interface AuthConfig {
baseUrl: string;
gotrueUrl: string;
adminEmail: string;
adminPassword: string;
}

const defaultConfig: AuthConfig = {
baseUrl: process.env.APPFLOWY_BASE_URL || 'http://localhost:8000',
gotrueUrl: 'http://localhost:9999',
adminEmail: process.env.GOTRUE_ADMIN_EMAIL || 'admin@example.com',
adminPassword: process.env.GOTRUE_ADMIN_PASSWORD || 'password',
};

export function generateRandomEmail(): string {
const rand = Math.random().toString(36).substring(2, 10);
return `test_${rand}_${Date.now()}@test.com`;
}

/**
* Full sign-in flow: creates user, gets tokens, sets localStorage, navigates to /app
*/
export async function signInAndNavigate(page: Page, config?: Partial<AuthConfig>): Promise<void> {
const cfg = { ...defaultConfig, ...config };
const email = generateRandomEmail();

// 1. Get admin token
let res = await fetch(`${cfg.gotrueUrl}/token?grant_type=password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: cfg.adminEmail, password: cfg.adminPassword }),
});
if (!res.ok) throw new Error(`Admin sign-in failed: ${res.status}`);
const adminData = await res.json();

// 2. Generate action link
res = await fetch(`${cfg.gotrueUrl}/admin/generate_link`, {
method: 'POST',
headers: { Authorization: `Bearer ${adminData.access_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, type: 'magiclink', redirect_to: 'http://localhost:3000' }),
});
if (!res.ok) throw new Error(`Generate link failed: ${res.status}`);
const linkData = await res.json();

// 3. Follow action link redirect to get tokens
res = await fetch(linkData.action_link, { redirect: 'manual' });
const location = res.headers.get('location');
let callbackLink: string;
if (location) {
const redirectUrl = new URL(location, linkData.action_link);
callbackLink = redirectUrl.pathname.substring(1) + redirectUrl.hash;
} else {
const html = await res.text();
const match = html.match(/<a[^>]*href=["']([^"']+)["']/);
if (!match?.[1]) throw new Error('Could not extract sign-in URL');
callbackLink = match[1].replace(/&amp;/g, '&');
}

// 4. Parse tokens from hash
const hashIndex = callbackLink.indexOf('#');
if (hashIndex === -1) throw new Error('No hash in callback link');
const params = new URLSearchParams(callbackLink.substring(hashIndex + 1));
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
if (!accessToken || !refreshToken) throw new Error('Missing tokens');

// 5. Verify user (create profile)
for (let i = 0; i < 3; i++) {
const verifyRes = await fetch(`${cfg.baseUrl}/api/user/verify/${accessToken}`);
if (verifyRes.ok || (verifyRes.status !== 502 && verifyRes.status !== 503)) break;
await new Promise(r => setTimeout(r, 2000));
}

// 6. Refresh token
res = await fetch(`${cfg.gotrueUrl}/token?grant_type=refresh_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
const tokenData = await res.json();

// 7. Set test mode and localStorage, then navigate
await page.addInitScript(() => {
(window as any).Cypress = true;
});

await page.goto('http://localhost:3000');
await page.evaluate((data) => {
localStorage.setItem('af_auth_token', data.access_token);
localStorage.setItem('af_refresh_token', data.refresh_token || data.originalRefresh);
if (data.user) localStorage.setItem('af_user_id', data.user.id);
localStorage.setItem('token', JSON.stringify(data));
}, { ...tokenData, originalRefresh: refreshToken });

await page.goto('http://localhost:3000/app');
await page.waitForURL(/\/app/, { timeout: 30000 });
}

/**
* Create a new document page by clicking the '+' button on the first space
*/
export async function createNewDocumentPage(page: Page): Promise<void> {
// Click the '+' button on the first space (General)
const addBtn = page.locator('[data-testid="inline-add-page"]').first();
await addBtn.click();

// Click "Document" in the dropdown menu
const docOption = page.getByRole('menuitem', { name: 'Document' });
await docOption.click();

// Wait for navigation to the new page
await page.waitForTimeout(2000);

// Wait for editor to be ready
await page.locator('[data-testid="editor-content"]').waitFor({ state: 'visible', timeout: 10000 });
}

/**
* Insert a video block via the slash command menu
*/
export async function insertVideoBlockViaSlash(page: Page): Promise<void> {
// Click at the beginning of the editor to ensure focus
const editor = page.locator('[data-testid="editor-content"]');
await editor.click({ force: true, position: { x: 100, y: 10 } });
await page.waitForTimeout(300);

// Type /video to open slash menu
await page.keyboard.type('/video');
await page.waitForTimeout(800);

// Click the video option
const videoOption = page.locator('[data-testid="slash-menu-video"]');
await videoOption.waitFor({ state: 'visible', timeout: 5000 });
await videoOption.click();
await page.waitForTimeout(500);
}

/**
* Enter a URL in the embed link input and submit
*/
export async function enterEmbedUrl(page: Page, url: string): Promise<void> {
// The embed popover should have an input field
const embedInput = page.locator('.embed-block input').first();
await embedInput.waitFor({ state: 'visible', timeout: 5000 });
await embedInput.fill(url);
await page.waitForTimeout(300);
}

/**
* Submit the embed link by pressing Enter
*/
export async function submitEmbedLink(page: Page): Promise<void> {
await page.keyboard.press('Enter');
await page.waitForTimeout(1500);
}
238 changes: 238 additions & 0 deletions playwright/e2e/video-embed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { test, expect, Page } from '@playwright/test';
import { signInAndNavigate } from './support/auth-utils';

/**
* BDD Playwright tests for Video Embed Block feature
* Covers: URL validation, normalization, paste handling, error display, and edge cases
*/

test.describe('Feature: Video Embed Block', () => {
// Run serially — each test creates a new user via GoTrue which can't handle parallel auth requests
test.describe.configure({ mode: 'serial' });

let page: Page;

test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
await signInAndNavigate(page);
// Wait for the app to load by checking for the add page button
await page.locator('[data-testid="inline-add-page"]').first().waitFor({ state: 'visible', timeout: 30000 });
});

test.afterEach(async () => {
await page.close();
});

/**
* Helper: Create a new document page (opens in modal)
*/
async function createNewDocPage() {
const addBtn = page.locator('[data-testid="inline-add-page"]').first();
await addBtn.click();
await page.waitForTimeout(500);
await page.getByText('Document', { exact: true }).first().click();
await page.waitForTimeout(2000);
}

/**
* Helper: Get the editor (last one = the one in the modal)
*/
function getEditor() {
return page.locator('[data-testid="editor-content"]').last();
}

/**
* Helper: Insert video block via slash command
*/
async function insertVideoBlock() {
const editor = getEditor();
await editor.click({ force: true, position: { x: 100, y: 10 } });
await page.waitForTimeout(300);
await page.keyboard.type('/video');
await page.waitForTimeout(800);
await page.locator('[data-testid="slash-menu-video"]').click();
await page.waitForTimeout(800);
}

/**
* Helper: Get the embed link input
*/
function getEmbedInput() {
return page.locator('input[placeholder*="video link"]');
}

/**
* Helper: Fill URL and check if validation error appears
*/
async function fillUrlAndCheckValidation(url: string): Promise<boolean> {
const input = getEmbedInput();
await input.fill(url);
await page.waitForTimeout(500);
const errorIndicator = page.locator('.text-text-error');
return await errorIndicator.isVisible();
}

// ─────────────────────────────────────────────────────────
// Scenario: Insert video via slash command with valid URL
// ─────────────────────────────────────────────────────────
test('Given a new page, when user inserts a YouTube video via slash command, then a video player renders', async () => {
await createNewDocPage();
await insertVideoBlock();

// Enter a valid YouTube URL
const input = getEmbedInput();
await input.fill('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
await page.waitForTimeout(300);

// No validation error should appear
const hasError = await page.locator('.text-text-error').isVisible();
expect(hasError).toBe(false);

// Submit
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);

// Video block should show the player (not the empty/error state)
const embedBlock = page.locator('.embed-block').last();
await expect(embedBlock).toBeVisible();
// The embed block should NOT have error styling
const errorText = embedBlock.locator('.text-function-error');
await expect(errorText).toHaveCount(0);
});

// ─────────────────────────────────────────────────────────
// Scenario: Paste a video URL to create a video block
// ─────────────────────────────────────────────────────────
test('Given a new page, when user pastes a YouTube URL, then a video block is created', async () => {
await createNewDocPage();

const editor = getEditor();
await editor.click({ force: true, position: { x: 100, y: 10 } });
await page.waitForTimeout(300);

// Paste a YouTube URL via clipboard event
await page.evaluate((url) => {
const clipboardData = new DataTransfer();
clipboardData.setData('text/plain', url);
const event = new ClipboardEvent('paste', {
clipboardData,
bubbles: true,
cancelable: true,
});
document.querySelectorAll('[data-slate-editor="true"]')[1]?.dispatchEvent(event);
}, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
await page.waitForTimeout(3000);

// A video embed block should appear
const embedBlock = page.locator('.embed-block').last();
await expect(embedBlock).toBeVisible({ timeout: 10000 });
});

// ─────────────────────────────────────────────────────────
// Scenario: Protocol-less URL is normalized (Bug fix)
// ─────────────────────────────────────────────────────────
test('Given a video block, when user enters a URL without protocol, then it is accepted after normalization', async () => {
await createNewDocPage();
await insertVideoBlock();

// Enter URL without protocol
const hasError = await fillUrlAndCheckValidation('youtube.com/watch?v=dQw4w9WgXcQ');

// Should NOT show error — processUrl normalizes to https://
expect(hasError).toBe(false);
});

// ─────────────────────────────────────────────────────────
// Scenario: Audio-only URLs are rejected (Bug fix)
// ─────────────────────────────────────────────────────────
test('Given a video block, when user enters an audio-only .mp3 URL, then it is rejected', async () => {
await createNewDocPage();
await insertVideoBlock();

// Enter an audio-only URL
const hasError = await fillUrlAndCheckValidation('https://example.com/audio.mp3');

// Should show error — audio files are not valid video URLs
expect(hasError).toBe(true);
});

// ─────────────────────────────────────────────────────────
// Scenario: Case-insensitive protocol accepted (Bug fix)
// ─────────────────────────────────────────────────────────
test('Given a video block, when user enters URL with uppercase HTTPS://, then it is accepted', async () => {
await createNewDocPage();
await insertVideoBlock();

// Enter URL with uppercase protocol
const hasError = await fillUrlAndCheckValidation('HTTPS://www.youtube.com/watch?v=dQw4w9WgXcQ');

// Should NOT show error
expect(hasError).toBe(false);
});

// ─────────────────────────────────────────────────────────
// Scenario: Invalid non-video URL shows validation error
// ─────────────────────────────────────────────────────────
test('Given a video block, when user enters a non-video URL, then validation error appears', async () => {
await createNewDocPage();
await insertVideoBlock();

// Enter a non-video URL
const hasError = await fillUrlAndCheckValidation('https://example.com/document.pdf');

// Should show error — PDF is not a video
expect(hasError).toBe(true);
});

// ─────────────────────────────────────────────────────────
// Scenario: Dangerous protocols are rejected
// ─────────────────────────────────────────────────────────
test('Given a video block, when user enters javascript: URL, then validation error appears', async () => {
await createNewDocPage();
await insertVideoBlock();

// Enter a dangerous protocol URL
const hasError = await fillUrlAndCheckValidation('javascript:alert(1)');

// Should show error — dangerous protocol
expect(hasError).toBe(true);
});

// ─────────────────────────────────────────────────────────
// Scenario: Vimeo URL is accepted
// ─────────────────────────────────────────────────────────
test('Given a video block, when user enters a Vimeo URL, then it is accepted', async () => {
await createNewDocPage();
await insertVideoBlock();

const hasError = await fillUrlAndCheckValidation('https://vimeo.com/148751763');
expect(hasError).toBe(false);
});

// ─────────────────────────────────────────────────────────
// Scenario: Direct .mp4 file URL is accepted
// ─────────────────────────────────────────────────────────
test('Given a video block, when user enters a direct .mp4 URL, then it is accepted', async () => {
await createNewDocPage();
await insertVideoBlock();

const hasError = await fillUrlAndCheckValidation('https://example.com/video.mp4');
expect(hasError).toBe(false);
});

// ─────────────────────────────────────────────────────────
// Scenario: Empty video block shows embed prompt
// ─────────────────────────────────────────────────────────
test('Given a new page, when user inserts a video block, then the embed popover appears', async () => {
await createNewDocPage();
await insertVideoBlock();

// The embed block should be visible with the "Embed a video" text
const embedBlock = page.locator('.embed-block').last();
await expect(embedBlock).toBeVisible();

// The embed link input should be visible
const input = getEmbedInput();
await expect(input).toBeVisible();
});
});
Loading
Loading