snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

27
tests/kit/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@affine-test/kit",
"private": true,
"type": "module",
"version": "0.27.5",
"exports": {
"./electron": "./src/electron.ts",
"./mobile": "./src/mobile.ts",
"./playwright": "./src/playwright.ts",
"./utils/*": "./src/utils/*.ts",
"./bs/*": "./src/bs/*.ts"
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@node-rs/argon2": "^2.0.2",
"@playwright/test": "=1.58.2",
"@toeverything/infra": "workspace:*",
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5"
},
"peerDependencies": {
"@playwright/test": "*",
"express": "*",
"http-proxy-middleware": "*"
}
}

View File

@@ -0,0 +1,17 @@
import type { Page } from '@playwright/test';
export function toolbarButtons(page: Page) {
const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
const switchViewBtn = toolbar.getByLabel('Switch view');
const inlineViewBtn = toolbar.getByLabel('Inline view');
const cardViewBtn = toolbar.getByLabel('Card view');
const embedViewBtn = toolbar.getByLabel('Embed view');
return {
toolbar,
switchViewBtn,
inlineViewBtn,
cardViewBtn,
embedViewBtn,
};
}

7
tests/kit/src/bs/misc.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { Page } from '@playwright/test';
export async function waitNextFrame(page: Page) {
await page.evaluate(
() => new Promise(resolve => requestAnimationFrame(resolve))
);
}

53
tests/kit/src/bs/table.ts Normal file
View File

@@ -0,0 +1,53 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { waitNextFrame } from './misc';
export async function createTable(page: Page) {
await page.keyboard.press('/');
await expect(page.locator('affine-slash-menu .slash-menu')).toBeVisible();
await page.keyboard.type('table');
await page.keyboard.press('Enter');
await waitNextFrame(page);
const table = page.locator('affine-table');
await expect(table).toBeVisible();
const cells = table.locator('affine-table-cell');
const cellCount = await cells.count();
expect(cellCount).toBe(4);
for (let i = 0; i < cellCount; i++) {
await inputToCell(page, i, `Cell${i + 1}`);
}
}
export async function getCellText(page: Page, nth: number, table?: Locator) {
table = table ?? page.locator('affine-table');
const cell = table.locator('affine-table-cell').nth(nth);
await cell.hover();
await waitNextFrame(page);
return cell.locator('v-line').innerText();
}
export async function inputToCell(
page: Page,
nth: number,
text: string,
table?: Locator
) {
table = table ?? page.locator('affine-table');
const cell = table.locator('affine-table-cell').nth(nth);
await cell.hover();
await waitNextFrame(page);
await cell.click();
await cell.click();
await waitNextFrame(page);
await page.keyboard.type(text, { delay: 20 });
}
export async function clickDeleteButtonInTableMenu(page: Page) {
const menu = page.locator('affine-menu');
await expect(menu).toBeVisible();
const deleteButton = menu.getByText('Delete');
await expect(deleteButton).toBeVisible();
await deleteButton.click();
}

428
tests/kit/src/electron.ts Normal file
View File

@@ -0,0 +1,428 @@
import type { ChildProcess } from 'node:child_process';
import crypto from 'node:crypto';
import { setTimeout } from 'node:timers/promises';
import { Package } from '@affine-tools/utils/workspace';
import type { Page } from '@playwright/test';
import fs from 'fs-extra';
import type { ElectronApplication } from 'playwright';
import { _electron as electron } from 'playwright';
import treeKill from 'tree-kill';
import { test as base } from './playwright';
import { removeWithRetry } from './utils/utils';
const electronRoot = new Package('@affine/electron').path;
const initialActivePages = new WeakMap<ElectronApplication, Page>();
const treeKillAsync = (pid: number, signal: NodeJS.Signals) =>
new Promise<void>((resolve, reject) => {
treeKill(pid, signal, error => {
if (
!error ||
('code' in error &&
typeof error.code === 'string' &&
error.code === 'ESRCH')
) {
resolve();
return;
}
reject(error);
});
});
function generateUUID() {
return crypto.randomUUID();
}
type RoutePath = 'setting';
type StreamLike = {
destroyed?: boolean;
destroy?: () => void;
};
const tryDestroyStream = (stream: StreamLike | null | undefined) => {
if (!stream || stream.destroyed || typeof stream.destroy !== 'function') {
return;
}
try {
stream.destroy();
} catch {}
};
const releaseChildProcessHandles = (child: ChildProcess) => {
if (child.connected) {
try {
child.disconnect();
} catch {}
}
tryDestroyStream(child.stdin);
tryDestroyStream(child.stdout);
tryDestroyStream(child.stderr);
for (const stream of child.stdio) {
if (
stream !== child.stdin &&
stream !== child.stdout &&
stream !== child.stderr
) {
tryDestroyStream(stream as StreamLike | null | undefined);
}
}
};
const waitForChildProcessExit = (child: ChildProcess) =>
new Promise<void>(resolve => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
child.once('exit', () => resolve());
});
const withTimeoutFallback = async <T>(
promise: Promise<T>,
fallback: T,
timeoutMs = 1_000
) => {
try {
return await Promise.race([
promise,
setTimeout(timeoutMs).then(() => fallback),
]);
} catch {
return fallback;
}
};
const getPageId = async (page: Page) => {
return await withTimeoutFallback(
page.evaluate(() => {
return (window.__appInfo as any)?.viewId as string | undefined;
}),
undefined,
500
);
};
const isActivePage = async (page: Page) => {
return await withTimeoutFallback(
page.evaluate(async () => {
return (await (window as any).__apis?.ui.isActiveTab()) === true;
}),
false,
500
);
};
const isEditorPage = async (page: Page) => {
return await withTimeoutFallback(
page
.locator('v-line')
.count()
.then(count => count > 0),
false
);
};
const getActivePage = async (pages: Page[]) => {
const activeChecks = await Promise.all(
pages.map(async page => ((await isActivePage(page)) ? page : null))
);
for (const page of activeChecks) {
if (page) return page;
}
const contentPages = (
await Promise.all(
pages.map(async page => {
const pageId = await getPageId(page);
if (pageId === 'shell') {
return null;
}
if (pageId || (await isEditorPage(page))) {
return page;
}
return null;
})
)
).filter((page): page is Page => !!page);
if (contentPages.length > 0) {
return contentPages.at(-1) ?? null;
}
return null;
};
const getShellPage = async (pages: Page[]) => {
for (const page of pages) {
if ((await getPageId(page)) === 'shell') {
return page;
}
}
return null;
};
const getElectronPages = (electronApp: ElectronApplication) => {
const pages = new Set<Page>();
for (const page of electronApp.windows()) {
if (!page.isClosed()) {
pages.add(page);
}
}
for (const page of electronApp.context().pages()) {
if (!page.isClosed()) {
pages.add(page);
}
}
return [...pages];
};
const waitForElectronPage = async (
electronApp: ElectronApplication,
label: string,
getPage: (pages: Page[]) => Promise<Page | null>
) => {
const deadline =
Date.now() +
(process.env.CI && process.platform === 'darwin' ? 25_000 : 20_000);
while (Date.now() < deadline) {
const page = await getPage(getElectronPages(electronApp));
if (page) {
return page;
}
await setTimeout(250);
}
throw new Error(`Timed out waiting for ${label}`);
};
const cleanupElectronApp = async (electronApp: ElectronApplication) => {
const child = electronApp.process();
const waitForAppClose = () =>
new Promise<void>(resolve => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
electronApp.once('close', () => resolve());
});
const killProcess = () => {
try {
child.kill();
} catch {}
};
const closeWithTimeout = async () => {
const closeEvent = waitForAppClose();
const processExit = waitForChildProcessExit(child);
const pid = child.pid;
void electronApp.close().catch(() => {});
const controller = new AbortController();
const killAfterTimeout = setTimeout(10_000, undefined, {
signal: controller.signal,
})
.then(async () => {
if (child.exitCode !== null || child.signalCode !== null) return;
if (pid !== undefined) {
await treeKillAsync(pid, 'SIGKILL').catch(() => {
killProcess();
});
return;
}
killProcess();
})
.catch(error => {
if (error instanceof Error && error.name === 'AbortError') return;
throw error;
});
try {
await Promise.race([closeEvent, processExit, killAfterTimeout]);
} finally {
controller.abort();
await killAfterTimeout;
await Promise.race([closeEvent, processExit, setTimeout(5_000)]).catch(
() => {}
);
releaseChildProcessHandles(child);
}
};
if (process.env.CI && process.platform === 'linux') {
const pid = child.pid;
const closeEvent = waitForAppClose();
const processExit = waitForChildProcessExit(child);
await Promise.race([
Promise.all([
electronApp.close().catch(() => {}),
closeEvent,
processExit,
]),
setTimeout(2_000),
]).catch(() => {});
if (
pid !== undefined &&
child.exitCode === null &&
child.signalCode === null
) {
await treeKillAsync(pid, 'SIGKILL').catch(() => {});
}
releaseChildProcessHandles(child);
await Promise.race([closeEvent, processExit, setTimeout(5_000)]).catch(
() => {}
);
return;
}
await closeWithTimeout();
};
const forceKillElectronApp = async (electronApp: ElectronApplication) => {
const child = electronApp.process();
const pid = child.pid;
if (pid !== undefined) {
await treeKillAsync(pid, 'SIGKILL').catch(() => child.kill());
} else {
child.kill();
}
await Promise.race([waitForChildProcessExit(child), setTimeout(5_000)]).catch(
() => {}
);
releaseChildProcessHandles(child);
};
export const test = base.extend<{
electronApp: ElectronApplication;
shell: Page;
appInfo: {
appPath: string;
appData: string;
sessionData: string;
};
views: {
getActive: () => Promise<Page>;
};
router: {
goto: (path: RoutePath) => Promise<void>;
};
}>({
shell: async ({ electronApp }, use) => {
const shell = await waitForElectronPage(
electronApp,
'shell page',
getShellPage
);
await use(shell);
},
page: async ({ electronApp }, use) => {
const cached = initialActivePages.get(electronApp);
const page =
cached && !cached.isClosed()
? cached
: await waitForElectronPage(electronApp, 'active page', getActivePage);
await page.waitForSelector('v-line');
await use(page);
},
views: async ({ electronApp, page }, use) => {
void page;
await use({
getActive: async () => {
const view = await getActivePage(getElectronPages(electronApp));
return view || page;
},
});
},
// oxlint-disable-next-line no-empty-pattern
electronApp: async ({}, use) => {
const id = generateUUID();
const dist = electronRoot.join('dist').value;
const clonedDist = electronRoot.join('e2e-dist-' + id).value;
let electronApp: ElectronApplication | undefined;
try {
await fs.copy(dist, clonedDist);
const packageJson = await fs.readJSON(
electronRoot.join('package.json').value
);
packageJson.name = '@affine/electron-test-' + id;
packageJson.productName = 'AFFiNE Test ' + id;
packageJson.main = './main.js';
await fs.writeJSON(clonedDist + '/package.json', packageJson);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value) {
env[key] = value;
}
}
env.DEBUG = 'pw:browser';
env.SKIP_ONBOARDING = '1';
const launch = () =>
electron.launch({
args: [clonedDist],
env,
cwd: clonedDist,
colorScheme: 'light',
});
for (let attempt = 0; attempt < 2; attempt++) {
electronApp = await launch();
try {
const page = await waitForElectronPage(
electronApp,
'active page',
getActivePage
);
initialActivePages.set(electronApp, page);
break;
} catch (error) {
if (attempt > 0) {
throw error;
}
await forceKillElectronApp(electronApp);
electronApp = undefined;
}
}
if (!electronApp) {
throw new Error('Failed to launch electron app');
}
await use(electronApp);
} finally {
if (electronApp) {
await cleanupElectronApp(electronApp);
}
if (await fs.pathExists(clonedDist)) {
await removeWithRetry(clonedDist);
}
}
},
appInfo: async ({ electronApp }, use) => {
const appInfo = await electronApp.evaluate(async ({ app }) => {
return {
appPath: app.getAppPath(),
appData: app.getPath('appData'),
sessionData: app.getPath('sessionData'),
};
});
await use(appInfo);
},
});

27
tests/kit/src/mobile.ts Normal file
View File

@@ -0,0 +1,27 @@
import { expect } from '@playwright/test';
import { test as baseTest } from './playwright';
type CurrentDocCollection = {
meta: { id: string; flavour: string };
};
export const test = baseTest.extend<{
workspace: {
current: () => Promise<CurrentDocCollection>;
};
}>({
page: async ({ page }, use) => {
await page.goto('/');
const editor = page.locator('.affine-page-viewport[data-mode="page"]');
await expect(editor).toBeVisible({
timeout: 30 * 1000,
});
await page.goto('/');
await use(page);
console.log(
'Browser User Agent:',
await page.evaluate(() => navigator.userAgent)
);
},
});

120
tests/kit/src/playwright.ts Normal file
View File

@@ -0,0 +1,120 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { Path, ProjectRoot } from '@affine-tools/utils/path';
import type { BrowserContext } from '@playwright/test';
import { test as baseTest } from '@playwright/test';
export { Path, ProjectRoot };
export const testResultDir = ProjectRoot.join('test-results').value;
export const istanbulTempDir = process.env.ISTANBUL_TEMP_DIR
? path.resolve(process.env.ISTANBUL_TEMP_DIR)
: ProjectRoot.join('.nyc_output').value;
function generateUUID() {
return crypto.randomUUID();
}
export const enableCoverage = !!process.env.CI || !!process.env.COVERAGE;
type CurrentDocCollection = {
meta: { id: string; flavour: string };
};
export const skipOnboarding = async (context: BrowserContext) => {
await context.addInitScript(() => {
window.localStorage.setItem('app_config', '{"onBoarding":false}');
window.localStorage.setItem('dismissAiOnboarding', 'true');
window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
});
};
export const test = baseTest.extend<{
workspace: {
current: () => Promise<CurrentDocCollection>;
};
}>({
workspace: async ({ page }, use) => {
await use({
current: async () => {
return await page.evaluate(async () => {
if (!(globalThis as any).currentWorkspace) {
await new Promise<void>((resolve, reject) => {
globalThis.addEventListener(
'affine:workspace:change',
() => resolve(),
{
once: true,
}
);
setTimeout(() => reject(new Error('timeout')), 5000);
});
}
const currentWorkspace = (globalThis as any).currentWorkspace;
return {
meta: currentWorkspace.meta,
};
});
},
});
},
page: async ({ page, context }, use) => {
if (process.env.CPU_THROTTLE) {
const cdpSession = await context.newCDPSession(page);
await cdpSession.send('Emulation.setCPUThrottlingRate', {
rate: parseInt(process.env.CPU_THROTTLE),
});
}
await use(page);
},
context: async ({ context }, use) => {
// Force-mark the body so global.css knows we are in a test.
// This keeps animations ON (0.1s) for tests, but OFF (0ms) for battery users.
await context.addInitScript(() => {
window.addEventListener('DOMContentLoaded', () => {
document.body.classList.add('playwright-test');
});
});
// workaround for skipping onboarding redirect on the web
await skipOnboarding(context);
if (enableCoverage) {
await context.addInitScript(() =>
window.addEventListener('beforeunload', () =>
// @ts-expect-error window.__coverage__ is not typed
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))
)
);
await fs.promises.mkdir(istanbulTempDir, { recursive: true });
await context.exposeFunction(
'collectIstanbulCoverage',
(coverageJSON?: string) => {
if (coverageJSON)
fs.writeFileSync(
path.join(
istanbulTempDir,
`playwright_coverage_${generateUUID()}.json`
),
coverageJSON
);
}
);
}
await use(context);
if (enableCoverage) {
for (const page of context.pages()) {
await page.evaluate(() =>
// @ts-expect-error window.__coverage__ is not typed
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))
);
}
}
},
});

View File

@@ -0,0 +1,54 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export async function expectActiveTab(
page: Page,
index: number,
activeViewIndex = 0
) {
await expect(
page
.getByTestId('workbench-tab')
.nth(index)
.getByTestId('split-view-label')
.nth(activeViewIndex)
).toHaveAttribute('data-active', 'true');
}
export async function expectTabTitle(
page: Page,
index: number,
title: string | string[],
timeout?: number
) {
if (typeof title === 'string') {
await expect(page.getByTestId('workbench-tab').nth(index)).toContainText(
title,
{ timeout }
);
} else {
for (let i = 0; i < title.length; i++) {
await expect(
page
.getByTestId('workbench-tab')
.nth(index)
.getByTestId('split-view-label')
.nth(i)
).toContainText(title[i], { timeout });
}
}
}
export async function expectTabCount(page: Page, count: number) {
await expect(page.getByTestId('workbench-tab')).toHaveCount(count);
}
export async function closeTab(page: Page, index: number) {
await page.getByTestId('workbench-tab').nth(index).hover();
await page
.getByTestId('workbench-tab')
.nth(index)
.getByTestId('close-tab-button')
.click();
}

View File

@@ -0,0 +1,31 @@
import type { Page } from '@playwright/test';
import { Path } from '../playwright';
const fixturesDir = Path.dir(import.meta.url).join('../../../fixtures');
export async function importFile(
page: Page,
file: string,
fn?: (page: Page) => Promise<void>
) {
await page.evaluate(() => {
// Force fallback to input[type=file] in tests
// See https://github.com/microsoft/playwright/issues/8850
window.showOpenFilePicker = undefined;
});
const fileChooser = page.waitForEvent('filechooser');
if (fn) await fn(page);
await (await fileChooser).setFiles(fixturesDir.join(file).value);
}
export async function importAttachment(page: Page, file: string) {
// open slash menu
await importFile(page, file, async page => {
await page.keyboard.type('/attachment', { delay: 50 });
await page.keyboard.press('Enter');
});
}

View File

@@ -0,0 +1,24 @@
import type { Page } from '@playwright/test';
export async function pasteContent(
page: Page,
clipData: Record<string, unknown>
) {
await page.evaluate(
({ clipData }) => {
const e = new ClipboardEvent('paste', {
clipboardData: new DataTransfer(),
});
Object.defineProperty(e, 'target', {
writable: false,
value: document,
});
Object.keys(clipData).forEach(key => {
e.clipboardData?.setData(key, clipData[key] as string);
});
document.dispatchEvent(e);
},
{ clipData }
);
await page.waitForTimeout(100);
}

View File

@@ -0,0 +1,366 @@
import { createRequire } from 'node:module';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import {
clickNewPageButton,
waitForAllPagesLoad,
waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic';
import { clickSideBarSettingButton } from '@affine-test/kit/utils/sidebar';
import { Package } from '@affine-tools/utils/workspace';
import { faker } from '@faker-js/faker';
import { hash } from '@node-rs/argon2';
import type { BrowserContext, Cookie, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { type PrismaClient } from '@prisma/client';
import type { Assertions } from 'ava';
import { z } from 'zod';
export async function getCurrentMailMessageCount() {
const response = await fetch('http://localhost:8025/api/v2/messages');
const data = await response.json();
return data.total;
}
export async function getLatestMailMessage() {
const response = await fetch('http://localhost:8025/api/v2/messages');
const data = await response.json();
return data.items[0];
}
export async function getTokenFromLatestMailMessage<A extends Assertions>(
test?: A
) {
const tokenRegex = /token=3D([^"&]+)/;
const emailContent = await getLatestMailMessage();
const tokenMatch = emailContent.Content.Body.match(tokenRegex);
const token = tokenMatch
? decodeURIComponent(tokenMatch[1].replaceAll('=\r\n', ''))
: null;
test?.truthy(token);
return token;
}
export async function getLoginCookie(
context: BrowserContext
): Promise<Cookie | undefined> {
return (await context.cookies()).find(c => c.name === 'sid');
}
const cloudUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
password: z.string(),
});
const server = new Package('@affine/server');
const require = createRequire(server.srcPath.join('index.ts').toFileUrl());
export const runPrisma = async <T>(
cb: (prisma: PrismaClient) => Promise<T>
): Promise<T> => {
const { PrismaClient } = require('@prisma/client');
const client = new PrismaClient({
datasourceUrl:
process.env.DATABASE_URL ||
'postgresql://affine:affine@localhost:5432/affine',
});
await client.$connect();
try {
return await cb(client);
} finally {
await client.$disconnect();
}
};
export async function addUserToWorkspace(
workspaceId: string,
userId: string,
permission: number
) {
await runPrisma(async client => {
const workspace = await client.workspace.findUnique({
where: {
id: workspaceId,
},
});
if (workspace == null) {
throw new Error(`workspace ${workspaceId} not found`);
}
await client.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId,
role:
permission === 99 ? 'owner' : permission === 10 ? 'admin' : 'member',
state: 'active',
source: 'legacy',
},
});
});
}
export async function createRandomUser(): Promise<{
name: string;
email: string;
password: string;
id: string;
}> {
const startTime = Date.now();
const user = {
name: faker.internet.username(),
email: faker.internet.email().toLowerCase(),
password: '123456',
};
const result = await runPrisma(async client => {
await client.user.create({
data: {
...user,
emailVerifiedAt: new Date(),
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
password: await hash(user.password),
features: {
create: {
reason: 'created by test case',
activated: true,
name: 'free_plan_v1',
type: 1,
},
},
},
});
return await client.user.findUnique({
where: {
email: user.email,
},
});
});
const endTime = Date.now();
console.log(`createRandomUser takes: ${endTime - startTime}ms`);
cloudUserSchema.parse(result);
return {
...result,
password: user.password,
} as any;
}
export async function cleanupWorkspace(workspaceId: string): Promise<void> {
await runPrisma(async client => {
const ret = await client.snapshot.deleteMany({
where: { workspaceId, id: { not: workspaceId } },
});
console.error(ret);
});
}
export async function createRandomAIUser(): Promise<{
name: string;
email: string;
password: string;
id: string;
}> {
const user = {
name: faker.internet.username(),
email: faker.internet.email().toLowerCase(),
password: '123456',
};
const result = await runPrisma(async client => {
const created = await client.user.create({
data: {
...user,
emailVerifiedAt: new Date(),
password: await hash(user.password),
features: {
create: [
{
reason: 'created by test case',
activated: true,
name: 'free_plan_v1',
type: 1,
},
{
reason: 'created by test case',
activated: true,
name: 'unlimited_copilot',
type: 0,
},
],
},
},
});
await client.entitlement.create({
data: {
targetType: 'user',
targetId: created.id,
source: 'cloud_subscription',
plan: 'ai',
status: 'active',
subjectId: `test-ai:${created.id}`,
metadata: {
legacySync: false,
},
},
});
return created;
});
cloudUserSchema.parse(result);
return {
...result,
password: user.password,
} as any;
}
export async function deleteUser(email: string) {
await runPrisma(async client => {
await client.user.delete({
where: {
email,
},
});
});
}
export async function loginUser(
page: Page,
user: {
email: string;
password: string;
},
config?: {
isElectron?: boolean;
beforeLogin?: () => Promise<void>;
afterLogin?: () => Promise<void>;
}
) {
if (config?.isElectron !== true) {
await openHomePage(page);
await waitForEditorLoad(page);
}
await page.getByTestId('sidebar-user-avatar').click({
delay: 200,
});
await loginUserDirectly(page, user, config);
}
export async function loginUserDirectly(
page: Page,
user: {
email: string;
password: string;
},
config?: {
isElectron?: boolean;
beforeLogin?: () => Promise<void>;
afterLogin?: () => Promise<void>;
}
) {
await page.getByPlaceholder('Enter your email address').fill(user.email);
await page.getByTestId('continue-login-button').click({
delay: 200,
});
await page.getByTestId('password-input').fill(user.password);
if (config?.beforeLogin) {
await config.beforeLogin();
}
await page.waitForTimeout(200);
const signIn = page.getByTestId('sign-in-button');
await signIn.click();
await signIn.waitFor({ state: 'detached' });
await page.waitForTimeout(200);
if (config?.afterLogin) {
await config.afterLogin();
}
}
async function dismissBlockingModal(page: Page) {
const modal = page.locator('modal-transition-container [data-modal="true"]');
if (
!(await modal
.first()
.isVisible()
.catch(() => false))
) {
return;
}
const closeButton = page.getByTestId('modal-close-button').last();
if (await closeButton.isVisible().catch(() => false)) {
await closeButton.click({ timeout: 5000 });
} else {
await page.keyboard.press('Escape');
}
await expect(modal.first()).toBeHidden({ timeout: 10000 });
}
export async function enableCloudWorkspace(page: Page) {
await clickSideBarSettingButton(page);
await page.getByTestId('workspace-setting:preference').click();
await page.getByTestId('publish-enable-affine-cloud-button').click();
await page.getByTestId('confirm-enable-affine-cloud-button').click();
// wait for upload and delete local workspace
await page.waitForTimeout(2000);
await waitForAllPagesLoad(page);
await dismissBlockingModal(page);
await clickNewPageButton(page);
await waitForWorkspaceSynced(page);
}
export async function enableCloudWorkspaceFromShareButton(page: Page) {
const shareMenuButton = page.getByTestId('local-share-menu-button');
await expect(shareMenuButton).toBeVisible();
await shareMenuButton.click();
await expect(page.getByTestId('local-share-menu')).toBeVisible();
await page.getByTestId('share-menu-enable-affine-cloud-button').click();
await page.getByTestId('confirm-enable-affine-cloud-button').click();
// wait for upload and delete local workspace
await page.waitForTimeout(2000);
await waitForEditorLoad(page);
await dismissBlockingModal(page);
await clickNewPageButton(page);
await waitForWorkspaceSynced(page);
}
async function waitForWorkspaceSynced(page: Page) {
await page.evaluate(async () => {
const workspaceId = location.pathname.split('/')[2];
const workspace = (
window as typeof window & {
currentWorkspace?: {
engine: {
doc: {
waitForSynced(docId: string, abort: AbortSignal): Promise<void>;
};
};
};
}
).currentWorkspace;
if (!workspaceId || !workspace) {
throw new Error('Cloud workspace is unavailable');
}
const abort = AbortSignal.timeout(60_000);
await Promise.all([
workspace.engine.doc.waitForSynced(workspaceId, abort),
workspace.engine.doc.waitForSynced('db$docProperties', abort),
]);
});
}
export async function enableShare(page: Page) {
await page.getByTestId('cloud-share-menu-button').click();
await page.getByTestId('share-link-menu-trigger').click();
// wait for the menu to be visible
await page.waitForTimeout(500);
await page.getByTestId('share-link-menu-enable-share').click();
await expect(page.getByTestId('share-link-menu-trigger')).toHaveText(
'Read only',
{ timeout: 30_000 }
);
}

View File

@@ -0,0 +1,35 @@
import type { Page } from '@playwright/test';
export const dropFile = async (
page: Page,
selector: string,
fileContent: Buffer | string,
fileName: string,
fileType = ''
) => {
const buffer =
typeof fileContent === 'string'
? Buffer.from(fileContent, 'utf-8')
: fileContent;
const dataTransfer = await page.evaluateHandle(
async ({ bufferData, localFileName, localFileType }) => {
const dt = new DataTransfer();
const blobData = await fetch(bufferData).then(res => res.blob());
const file = new File([blobData], localFileName, { type: localFileType });
dt.items.add(file);
return dt;
},
{
bufferData: `data:application/octet-stream;base64,${buffer.toString(
'base64'
)}`,
localFileName: fileName,
localFileType: fileType,
}
);
await page.dispatchEvent(selector, 'drop', { dataTransfer });
};

View File

@@ -0,0 +1,231 @@
import type { Page } from '@playwright/test';
import { locateEditorContainer } from './editor';
export type CanvasRendererPerfSnapshot = {
heapMemory: {
jsHeapSizeLimit: number;
totalJSHeapSize: number;
usedJSHeapSize: number;
} | null;
layerSequence: Array<'block' | 'canvas'>;
metrics: Record<string, unknown> | null;
rendererType: string | null;
selectedIds: string[];
};
export type SeedEdgelessPerfSceneOptions = {
height?: number;
interleaved?: boolean;
noteCount?: number;
rowLength?: number;
shapeCount?: number;
startX?: number;
startY?: number;
width?: number;
};
export async function getCanvasRendererPerfSnapshot(
page: Page,
editorIndex = 0
): Promise<CanvasRendererPerfSnapshot> {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate(container => {
type PerfMemory = {
jsHeapSizeLimit: number;
totalJSHeapSize: number;
usedJSHeapSize: number;
};
type PerfRenderer = {
constructor?: { name?: string };
getDebugMetrics?: () => Record<string, unknown>;
resetDebugMetrics?: () => void;
};
const root = container.querySelector('affine-edgeless-root');
const surface = container.querySelector('affine-surface');
if (!root) {
throw new Error('Edgeless root not found');
}
if (!surface) {
throw new Error('Surface block not found');
}
const renderer = surface.renderer as PerfRenderer | undefined;
const metrics =
renderer &&
typeof renderer.getDebugMetrics === 'function' &&
renderer.constructor?.name === 'CanvasRenderer'
? renderer.getDebugMetrics()
: null;
const memory = (
performance as Performance & {
memory?: PerfMemory;
}
).memory;
return {
rendererType: renderer?.constructor?.name ?? null,
metrics,
selectedIds: [...root.gfx.selection.selectedIds],
layerSequence: root.gfx.layer.layers.map(
(layer: { type: 'block' | 'canvas' }) => layer.type
),
heapMemory: memory
? {
jsHeapSizeLimit: memory.jsHeapSizeLimit,
totalJSHeapSize: memory.totalJSHeapSize,
usedJSHeapSize: memory.usedJSHeapSize,
}
: null,
};
});
}
export async function resetCanvasRendererPerfMetrics(
page: Page,
editorIndex = 0
) {
const container = locateEditorContainer(page, editorIndex);
await container.evaluate(container => {
type PerfRenderer = {
resetDebugMetrics?: () => void;
};
const surface = container.querySelector('affine-surface');
if (!surface) {
throw new Error('Surface block not found');
}
const renderer = surface.renderer as PerfRenderer | undefined;
if (!renderer || typeof renderer.resetDebugMetrics !== 'function') {
throw new Error('Canvas renderer debug metrics are unavailable');
}
renderer.resetDebugMetrics();
});
}
export async function seedEdgelessPerfScene(
page: Page,
options: SeedEdgelessPerfSceneOptions = {},
editorIndex = 0
) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container, options) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
const doc = root.service.doc;
const nextIndex = root.gfx.layer.createIndexGenerator();
const shapeCount = options.shapeCount ?? 120;
const noteCount = options.noteCount ?? 0;
const rowLength = options.rowLength ?? 12;
const width = options.width ?? 140;
const height = options.height ?? 100;
const startX = options.startX ?? 80;
const startY = options.startY ?? 160;
const interleaved = options.interleaved ?? false;
const gapX = width + 36;
const gapY = height + 36;
let shapeCursor = 0;
let noteCursor = 0;
const shapeIds: string[] = [];
const noteIds: string[] = [];
const getPosition = (cursor: number) => {
const row = Math.floor(cursor / rowLength);
const col = cursor % rowLength;
return {
x: startX + col * gapX,
y: startY + row * gapY,
};
};
const addShape = () => {
const { x, y } = getPosition(shapeCursor++);
const id = root.service.crud.addElement('shape', {
index: nextIndex(),
shapeType: 'rect',
xywh: `[${x}, ${y}, ${width}, ${height}]`,
});
if (id) {
shapeIds.push(id);
}
};
const addNote = () => {
const { x, y } = getPosition(noteCursor++);
const noteId = doc.addBlock(
'affine:note',
{
index: nextIndex(),
xywh: `[${x}, ${y}, ${Math.max(width * 2, 260)}, ${height}]`,
},
doc.root
);
doc.addBlock('affine:paragraph', {}, noteId);
noteIds.push(noteId);
};
if (interleaved) {
const maxCount = Math.max(shapeCount, noteCount);
for (let i = 0; i < maxCount; i++) {
if (i < noteCount) {
addNote();
}
if (i < shapeCount) {
addShape();
}
}
} else {
for (let i = 0; i < shapeCount; i++) {
addShape();
}
for (let i = 0; i < noteCount; i++) {
addNote();
}
}
return { noteIds, shapeIds };
}, options);
}
export async function deleteEdgelessElements(
page: Page,
ids: string[],
editorIndex = 0
) {
const container = locateEditorContainer(page, editorIndex);
await container.evaluate((container, ids) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
const doc = root.service.doc;
ids.forEach(id => {
const element = root.service.crud.getElementById(id);
if (element) {
root.service.removeElement(id);
return;
}
if (doc.getBlock(id)) {
doc.deleteBlock(id);
}
});
}, ids);
}

View File

@@ -0,0 +1,554 @@
import '@blocksuite/affine/effects';
import type { IVec, XYWH } from '@blocksuite/affine/global/gfx';
import type { CodeBlockComponent } from '@blocksuite/affine-block-code';
import type { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import type { BlockComponent } from '@blocksuite/std';
import { expect, type Locator, type Page } from '@playwright/test';
import { dblclickLocatorByRatio } from './utils';
const EDGELESS_TOOLBAR_WIDGET = 'edgeless-toolbar-widget';
export const ZERO_WIDTH_FOR_EMPTY_LINE =
process.env.BROWSER === 'webkit' ? '\u200C' : '\u200B';
export function inlineEditorInnerTextToString(innerText: string): string {
return innerText.replace(ZERO_WIDTH_FOR_EMPTY_LINE, '').trim();
}
const PARAGRAPH_BLOCK_LOCATOR = 'affine-paragraph';
const CODE_BLOCK_LOCATOR = 'affine-code';
export function locateModeSwitchButton(
page: Page,
mode: 'page' | 'edgeless',
active?: boolean
) {
// switch is implemented as RadioGroup button,
// so we can use aria-checked to determine the active state
const checkedSelector = active ? '[aria-checked="true"]' : '';
return page.locator(
`[data-testid="switch-${mode}-mode-button"]${checkedSelector}`
);
}
export async function clickEdgelessModeButton(page: Page) {
await locateModeSwitchButton(page, 'edgeless').click({ delay: 50 });
await ensureInEdgelessMode(page);
}
export async function clickPageModeButton(page: Page) {
await locateModeSwitchButton(page, 'page').click({ delay: 50 });
await ensureInPageMode(page);
}
export async function ensureInPageMode(page: Page) {
await expect(locateModeSwitchButton(page, 'page', true)).toBeVisible();
}
export async function ensureInEdgelessMode(page: Page) {
await expect(locateModeSwitchButton(page, 'edgeless', true)).toBeVisible();
// wait zoom animation
await page.waitForTimeout(500);
}
export async function getPageMode(page: Page): Promise<'page' | 'edgeless'> {
if (await locateModeSwitchButton(page, 'page', true).isVisible()) {
return 'page';
}
if (await locateModeSwitchButton(page, 'edgeless', true).isVisible()) {
return 'edgeless';
}
throw new Error('Unknown mode');
}
export function locateEditorContainer(page: Page, editorIndex = 0) {
return page.locator('[data-affine-editor-container]').nth(editorIndex);
}
export async function dblclickNoteBody(page: Page) {
const note = page.locator('affine-edgeless-note');
await dblclickLocatorByRatio(page, note, { yRatio: 0.7 });
}
export function locateDocTitle(page: Page, editorIndex = 0) {
return locateEditorContainer(page, editorIndex).locator('doc-title');
}
export function isDocTitleFocused(page: Page, editorIndex = 0) {
return locateDocTitle(page, editorIndex)
.locator('.inline-editor')
.evaluate(inlineEditor => {
return document.activeElement === inlineEditor;
});
}
export async function focusDocTitle(page: Page, editorIndex = 0) {
await locateDocTitle(page, editorIndex).locator('.inline-editor').focus();
}
export async function assertTitle(page: Page, text: string) {
const title = locateDocTitle(page);
const inlineEditor = title.locator('.doc-title-container').first();
const vText = inlineEditorInnerTextToString(await inlineEditor.innerText());
expect(vText).toBe(text);
}
export function locateToolbar(page: Page, editorIndex = 0) {
return locateEditorContainer(page, editorIndex).locator(
'affine-toolbar-widget editor-toolbar'
);
}
// ================== Edgeless ==================
export async function getEdgelessSelectedIds(page: Page, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate(container => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
return root.gfx.selection.selectedIds;
});
}
export async function getSelectedXYWH(
page: Page,
index = 0,
editorIndex = 0
): Promise<[number, number, number, number]> {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container, index) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
const selected = root.service.selection.selectedElements[index];
return selected.elementBound.toXYWH();
}, index);
}
export async function getViewportCenter(page: Page, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate(container => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
return root.gfx.viewport.center;
});
}
export async function getViewportBound(page: Page, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate(container => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
return root.gfx.viewport.viewportBounds.toXYWH();
});
}
export async function setViewportCenter(
page: Page,
center: IVec,
editorIndex = 0
) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container, center) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
root.gfx.viewport.setCenter(center[0], center[1]);
}, center);
}
export async function setViewportZoom(page: Page, zoom = 1, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container, zoom) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
root.gfx.viewport.setZoom(zoom);
}, zoom);
}
export async function fitViewportToContent(page: Page, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate(container => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
root.gfx.fitToScreen();
});
}
/**
* Convert a canvas point to view coordinate
* @param point the coordinate on the canvas
*/
export async function toViewCoord(page: Page, point: IVec, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container, point) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
const coord = root.gfx.viewport.toViewCoord(point[0], point[1]);
coord[0] += root.gfx.viewport.left;
coord[1] += root.gfx.viewport.top;
return coord;
}, point);
}
/**
* Convert a view coordinate to canvas point
* @param point the coordinate on the view
*/
export async function toModelCoord(page: Page, point: IVec, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container, point) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
return root.gfx.viewport.toModelCoordFromClientCoord(point);
}, point);
}
/**
* Move to a point on the canvas
*/
export async function moveToView(page: Page, point: IVec, editorIndex = 0) {
const [x, y] = await toViewCoord(page, point, editorIndex);
await page.mouse.move(x, y);
}
/**
* Click a point on the canvas
* @param point the coordinate on the canvas
*/
export async function clickView(page: Page, point: IVec, editorIndex = 0) {
const [x, y] = await toViewCoord(page, point, editorIndex);
await page.mouse.click(x, y);
}
/**
* Double click a point on the canvas
* @param point the coordinate on the canvas
*/
export async function dblclickView(page: Page, point: IVec, editorIndex = 0) {
const [x, y] = await toViewCoord(page, point, editorIndex);
await page.mouse.dblclick(x, y);
}
export async function dragView(
page: Page,
from: IVec,
to: IVec,
editorIndex = 0
) {
const steps = 10;
const [x1, y1] = await toViewCoord(page, from, editorIndex);
const [x2, y2] = await toViewCoord(page, to, editorIndex);
await page.mouse.move(x1, y1);
await page.mouse.down();
await page.mouse.move(x2, y2, { steps });
await page.mouse.up();
}
export function locateEdgelessToolbar(page: Page, editorIndex = 0) {
return locateEditorContainer(page, editorIndex).locator(
EDGELESS_TOOLBAR_WIDGET
);
}
type EdgelessTool =
| 'default'
| 'pan'
| 'note'
| 'shape'
| 'pen'
| 'brush'
| 'highlighter'
| 'eraser'
| 'text'
| 'connector'
| 'frame'
| 'frameNavigator';
/**
* @param type the type of the tool in the toolbar
* @param innerContainer the button may have an inner container
*/
export async function locateEdgelessToolButton(
page: Page,
type: EdgelessTool,
innerContainer = true,
editorIndex = 0
) {
const toolbar = locateEdgelessToolbar(page, editorIndex);
const selector = {
default: '.edgeless-default-button',
pan: '.edgeless-default-button',
shape: '.edgeless-shape-button',
pen: '.edgeless-pen-button',
brush: '.edgeless-brush-button',
highlighter: '.edgeless-highlighter-button',
eraser: '.edgeless-eraser-button',
text: '.edgeless-mindmap-button',
connector: '.edgeless-connector-button',
note: '.edgeless-note-button',
frame: '.edgeless-frame-button',
frameNavigator: '.edgeless-frame-navigator-button',
}[type];
let buttonType;
switch (type) {
case 'brush':
case 'highlighter':
buttonType = 'edgeless-tool-icon-button';
break;
case 'pen':
case 'text':
case 'eraser':
case 'shape':
case 'note':
buttonType = 'edgeless-toolbar-button';
break;
default:
buttonType = 'edgeless-tool-icon-button';
}
const locateEdgelessToolButtonSenior = async (
selector: string
): Promise<Locator> => {
const target = toolbar.locator(selector);
const visible = await target.isVisible();
if (visible) return target;
// try to click next page
const nextButton = toolbar.locator(
'.senior-nav-button-wrapper.next > icon-button'
);
const nextExists = await nextButton.count();
const isDisabled =
// oxlint-disable-next-line unicorn/prefer-dom-node-dataset
(await nextButton.getAttribute('data-test-disabled')) === 'true';
if (!nextExists || isDisabled) return target;
await nextButton.click();
await page.waitForTimeout(200);
return locateEdgelessToolButtonSenior(selector);
};
const button = await locateEdgelessToolButtonSenior(
`${buttonType}${selector}`
);
return innerContainer ? button.locator('.icon-container') : button;
}
export enum Shape {
Diamond = 'Diamond',
Ellipse = 'Ellipse',
'Rounded rectangle' = 'Rounded rectangle',
Square = 'Square',
Triangle = 'Triangle',
}
/**
* Set edgeless tool by clicking button in edgeless toolbar
*/
export async function setEdgelessTool(
page: Page,
tool: EdgelessTool,
shape = Shape.Square,
editorIndex = 0
) {
const toolbar = locateEdgelessToolbar(page, editorIndex);
switch (tool) {
// text tool is removed, use shortcut to trigger
case 'text':
await page.keyboard.press('t', { delay: 100 });
break;
case 'default': {
const button = await locateEdgelessToolButton(
page,
'default',
false,
editorIndex
);
const classes = (await button.getAttribute('class'))?.split(' ');
if (!classes?.includes('default')) {
await button.click();
await page.waitForTimeout(100);
}
break;
}
case 'pan': {
const button = await locateEdgelessToolButton(
page,
'default',
false,
editorIndex
);
const classes = (await button.getAttribute('class'))?.split(' ');
if (classes?.includes('default')) {
await button.click();
await page.waitForTimeout(100);
} else if (classes?.includes('pan')) {
await button.click(); // change to default
await page.waitForTimeout(100);
await button.click(); // change to pan
await page.waitForTimeout(100);
}
break;
}
case 'brush':
case 'highlighter': {
const penButton = await locateEdgelessToolButton(
page,
'pen',
false,
editorIndex
);
await penButton.click();
await page.waitForTimeout(250);
const button = await locateEdgelessToolButton(
page,
tool,
false,
editorIndex
);
await button.click();
break;
}
case 'note':
case 'eraser':
case 'frame':
case 'connector': {
const button = await locateEdgelessToolButton(
page,
tool,
false,
editorIndex
);
await button.click();
break;
}
case 'shape': {
const shapeToolButton = await locateEdgelessToolButton(
page,
'shape',
false,
editorIndex
);
// Avoid clicking on the shape-element (will trigger dragging mode)
await shapeToolButton.click({ position: { x: 5, y: 5 } });
const squareShapeButton = toolbar
.locator('edgeless-slide-menu edgeless-tool-icon-button')
.filter({ hasText: shape });
await squareShapeButton.click();
break;
}
}
}
export async function resizeElementByHandle(
page: Page,
delta: IVec,
corner:
| 'right'
| 'left'
| 'top'
| 'bottom'
| 'top-left'
| 'top-right'
| 'bottom-right'
| 'bottom-left' = 'top-left',
editorIndex = 0
) {
const handle = page.locator(`.handle[aria-label="${corner}"] .resize`);
const box = await handle.boundingBox();
if (box === null) throw new Error();
const from = await toModelCoord(
page,
[box.x + box.width / 2, box.y + box.height / 2],
editorIndex
);
const to: IVec = [from[0] + delta[0], from[1] + delta[1]];
await dragView(page, from, to, editorIndex);
}
export async function scaleElementByHandle(
page: Page,
delta: IVec,
corner:
| 'right'
| 'left'
| 'top'
| 'bottom'
| 'top-left'
| 'top-right'
| 'bottom-right'
| 'bottom-left' = 'top-left',
editorIndex = 0
) {
await page.keyboard.down('Shift');
await resizeElementByHandle(page, delta, corner, editorIndex);
await page.keyboard.up('Shift');
}
/**
* Create a not block in canvas
* @param position the position or xwyh of the note block in canvas
*/
export async function createEdgelessNoteBlock(
page: Page,
position: IVec | XYWH,
editorIndex = 0
) {
await setEdgelessTool(page, 'note', undefined, editorIndex);
if (position.length === 4) {
await dragView(
page,
[position[0], position[1]],
[position[0] + position[2], position[1] + position[3]]
);
} else {
await clickView(page, position, editorIndex);
}
}
// Helper function to get block ids
export async function getBlockIds<T extends BlockComponent>(
page: Page,
selector: string
) {
const blocks = page.locator(selector);
const blockIds = await blocks.evaluateAll((blocks: T[]) =>
blocks.map(block => block.model.id)
);
return { blockIds };
}
// Helper functions using the generic getBlockIds
export async function getParagraphIds(page: Page) {
return getBlockIds<ParagraphBlockComponent>(page, PARAGRAPH_BLOCK_LOCATOR);
}
// Helper functions using the generic getBlockIds
export async function getCodeBlockIds(page: Page) {
return getBlockIds<CodeBlockComponent>(page, CODE_BLOCK_LOCATOR);
}

View File

@@ -0,0 +1,13 @@
import type { Page } from '@playwright/test';
// fixme: there could be multiple page lists in the Page
export const getPagesCount = async (page: Page) => {
const locator = page.locator('[data-testid="doc-list-item"]');
return await locator.count();
};
export async function selectTag(page: Page, name: string | RegExp) {
await page.getByTestId('filter-arg').click();
await page.getByTestId(`multi-select-${name}`).click();
await page.keyboard.press('Escape', { delay: 100 });
}

View File

@@ -0,0 +1,23 @@
import { Path } from '@affine-tools/utils/path';
import type { Page } from '@playwright/test';
export async function importImage(page: Page, pathInFixtures: string) {
await page.evaluate(() => {
// Force fallback to input[type=file] in tests
// See https://github.com/microsoft/playwright/issues/8850
window.showOpenFilePicker = undefined;
});
const fileChooser = page.waitForEvent('filechooser');
// open slash menu
await page.keyboard.type('/image', { delay: 50 });
await page.keyboard.press('Enter');
await (
await fileChooser
).setFiles(
Path.dir(import.meta.url).join('../../../fixtures', pathInFixtures).value
);
// TODO(@catsjuice): wait for image to be loaded more reliably
await page.waitForTimeout(1000);
}

View File

@@ -0,0 +1,136 @@
import { type Page } from '@playwright/test';
import { AsyncLock } from '@toeverything/infra/utils';
const IS_MAC = process.platform === 'darwin';
async function keyDownCtrlOrMeta(page: Page) {
if (IS_MAC) {
await page.keyboard.down('Meta');
} else {
await page.keyboard.down('Control');
}
}
async function keyUpCtrlOrMeta(page: Page) {
if (IS_MAC) {
await page.keyboard.up('Meta');
} else {
await page.keyboard.up('Control');
}
}
// It's not good enough, but better than calling keyDownCtrlOrMeta and keyUpCtrlOrMeta separately
export const withCtrlOrMeta = async (page: Page, fn: () => Promise<void>) => {
await keyDownCtrlOrMeta(page);
await fn();
await keyUpCtrlOrMeta(page);
};
export async function pressEnter(page: Page, count = 1) {
// avoid flaky test by simulate real user input
for (let i = 0; i < count; i++) {
await page.keyboard.press('Enter', { delay: 50 });
}
}
export async function pressArrowUp(page: Page, count = 1) {
for (let i = 0; i < count; i++) {
await page.keyboard.press('ArrowUp', { delay: 50 });
}
}
export async function pressArrowDown(page: Page, count = 1) {
for (let i = 0; i < count; i++) {
await page.keyboard.press('ArrowDown', { delay: 20 });
}
}
export async function pressTab(page: Page) {
await page.keyboard.press('Tab', { delay: 50 });
}
export async function pressShiftTab(page: Page) {
await page.keyboard.down('Shift');
await page.keyboard.press('Tab', { delay: 50 });
await page.keyboard.up('Shift');
}
export async function pressShiftEnter(page: Page) {
await page.keyboard.down('Shift');
await page.keyboard.press('Enter', { delay: 50 });
await page.keyboard.up('Shift');
}
export async function pressBackspace(page: Page, count = 1) {
for (let i = 0; i < count; i++) {
await page.keyboard.press('Backspace', { delay: 50 });
}
}
export async function pressEscape(page: Page, count = 1) {
for (let i = 0; i < count; i++) {
await page.keyboard.press('Escape', { delay: 50 });
}
}
export async function copyByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('c', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function cutByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('x', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function pasteByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('v', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function selectAllByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('a', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function undoByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('z', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
const clipboardMutex = new AsyncLock();
export async function writeTextToClipboard(
page: Page,
text: string,
paste = true
) {
using _release = await clipboardMutex.acquire();
// paste the url
await page.evaluate(
async ([text]) => {
// oxlint-disable-next-line typescript/no-floating-promises
navigator.clipboard.writeText('');
const e = new ClipboardEvent('paste', {
clipboardData: new DataTransfer(),
});
Object.defineProperty(e, 'target', {
writable: false,
value: document,
});
e.clipboardData!.setData('text/plain', text);
document.dispatchEvent(e);
},
[text]
);
if (paste) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('v', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
}

View File

@@ -0,0 +1,29 @@
import type { Page } from '@playwright/test';
export let coreUrl = 'http://localhost:8080';
export function setCoreUrl(url: string) {
coreUrl = url;
}
export async function openHomePage(page: Page) {
await page.goto(coreUrl);
}
export async function open404Page(page: Page) {
await page.goto(`${coreUrl}/404`);
}
export async function confirmCreateJournal(page: Page) {
const confirmButton = page.getByTestId('confirm-create-journal-button');
await confirmButton.click();
}
export async function openJournalsPage(page: Page) {
const { expect } = await import('@playwright/test');
await page.getByTestId('slider-bar-journals-button').click();
await confirmCreateJournal(page);
await expect(
page.locator('.doc-title-container:has-text("Today")')
).toBeVisible();
}

View File

@@ -0,0 +1,266 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { openEditorInfoPanel } from './setting';
export function getAllPage(page: Page) {
const newPageButton = page.getByTestId('new-page-button-trigger');
const newPageDropdown = newPageButton.locator('svg');
const edgelessBlockCard = page.getByTestId('new-edgeless-button-in-all-page');
async function clickNewPageButton() {
const newPageButton = page.getByTestId('new-page-button-trigger');
return await newPageButton.click({
timeout: 20000,
});
}
async function clickNewEdgelessDropdown() {
await newPageDropdown.click();
await edgelessBlockCard.click();
}
return { clickNewPageButton, clickNewEdgelessDropdown };
}
export async function waitForEditorLoad(page: Page) {
await page.waitForSelector('v-line', {
timeout: 20000,
});
}
export async function waitForAllPagesLoad(page: Page) {
// if doc-list-item is rendered, we believe all_pages is ready
await page.waitForSelector('[data-testid="doc-list-item"]', {
timeout: 20000,
});
}
export async function clickNewPageButton(page: Page, title?: string) {
const newPageButton = page.getByTestId('sidebar-new-page-button');
await expect(newPageButton).toBeVisible({
timeout: 30000,
});
await newPageButton.click({
timeout: 30000,
});
await waitForEmptyEditor(page);
if (title) {
await getBlockSuiteEditorTitle(page).fill(title);
}
}
export async function waitForEmptyEditor(page: Page) {
await page.waitForSelector(
'.doc-title-container-empty, doc-title .inline-editor',
{ timeout: 20000 }
);
}
export function getBlockSuiteEditorTitle(page: Page) {
return page.locator('doc-title .inline-editor').nth(0);
}
export async function type(page: Page, content: string, delay = 50) {
await page.keyboard.type(content, { delay });
}
export const createLinkedPage = async (page: Page, pageName?: string) => {
await waitForEditorLoad(page);
await page.keyboard.type('@', { delay: 50 });
const linkedPagePopover = page.locator('.linked-doc-popover');
await expect(linkedPagePopover).toBeVisible();
await type(page, pageName || 'Untitled');
await linkedPagePopover
.locator(`icon-button`)
.filter({ hasText: `New "${pageName}" page` })
.click();
};
export const createSyncedPageInEdgeless = async (
page: Page,
pageName?: string
) => {
await page.keyboard.type('@', { delay: 50 });
const cmdkPopover = page.locator('[data-testid="cmdk-quick-search"]');
await expect(cmdkPopover).toBeVisible();
await type(page, pageName || 'Untitled');
await cmdkPopover
.locator('[cmdk-item][data-value="creation:create-page"]')
.click();
};
export const createTodayPage = async (page: Page) => {
// fixme: workaround for @ popover not showing up when editor is not ready
await page.waitForTimeout(500);
await page.keyboard.type('@', { delay: 50 });
const linkedPagePopover = page.locator('.linked-doc-popover');
await expect(linkedPagePopover).toBeVisible();
await type(page, 'Today');
await linkedPagePopover
.locator(`icon-button`)
.filter({ hasText: 'Today' })
.nth(0)
.click();
};
export async function clickPageMoreActions(page: Page) {
return page
.getByTestId('header')
.getByTestId('header-dropDownButton')
.click();
}
export const getPageOperationButton = (page: Page, id: string) => {
return getPageItem(page, id).getByTestId('doc-list-operation-button');
};
export const getPageItem = (page: Page, id: string) => {
return page.locator(`[data-doc-id="${id}"][data-testid="doc-list-item"]`);
};
export const getPageByTitle = (page: Page, title: string) => {
return page.getByTestId('doc-list-item').filter({
has: page.locator(
`[data-testid="doc-list-item-title"]:has-text("${title}")`
),
});
};
export type DragLocation =
| 'top-left'
| 'top'
| 'bottom'
| 'center'
| 'left'
| 'right';
export const toPosition = (
rect: {
x: number;
y: number;
width: number;
height: number;
},
location: DragLocation
) => {
switch (location) {
case 'center':
return {
x: rect.width / 2,
y: rect.height / 2,
};
case 'top':
return { x: rect.width / 2, y: 1 };
case 'bottom':
return { x: rect.width / 2, y: rect.height - 1 };
case 'left':
return { x: 1, y: rect.height / 2 };
case 'right':
return { x: rect.width - 1, y: rect.height / 2 };
case 'top-left':
default:
return { x: 1, y: 1 };
}
};
export const dragTo = async (
page: Page,
locator: Locator,
target: Locator,
location: DragLocation = 'center',
willMoveOnDrag = false
) => {
const locatorElement = await locator.boundingBox();
if (!locatorElement) {
throw new Error('locator element not found');
}
const locatorCenter = toPosition(locatorElement, 'center');
const start = {
x: locatorElement.x + locatorCenter.x,
y: locatorElement.y + locatorCenter.y,
};
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.waitForTimeout(100);
await page.mouse.move(start.x + 8, start.y + 8, { steps: 4 });
if (willMoveOnDrag) {
await target.hover();
} else {
const targetElement = await target.boundingBox();
if (!targetElement) {
throw new Error('target element not found');
}
const targetPosition = toPosition(targetElement, location);
await page.mouse.move(
targetElement.x + targetPosition.x,
targetElement.y + targetPosition.y,
{
steps: 24,
}
);
}
await page.waitForTimeout(200);
await page.mouse.up();
};
// sometimes editor loses focus, this function is to focus the editor
// FIXME: this function is not usable since the placeholder is not unstable
export const focusInlineEditor = async (page: Page) => {
await page
.locator(
`.affine-paragraph-rich-text-wrapper:has(.visible):has-text("Type '/' for commands")`
)
.locator('.inline-editor')
.focus();
};
export const addDatabase = async (page: Page, title?: string) => {
await page.keyboard.press('/');
await expect(page.locator('affine-slash-menu .slash-menu')).toBeVisible();
await page.keyboard.type('database');
await page.getByTestId('Table View').click();
if (title) {
await page.locator('affine-database-title').click();
await page
.locator(
'affine-database-title textarea[data-block-is-database-title="true"]'
)
.fill(title);
await page
.locator(
'affine-database-title textarea[data-block-is-database-title="true"]'
)
.blur();
}
};
export const addCodeBlock = async (page: Page) => {
await page.keyboard.press('/');
await expect(page.locator('affine-slash-menu .slash-menu')).toBeVisible();
await page.keyboard.type('code');
await page.getByTestId('Code Block').click();
};
export const addDatabaseRow = async (page: Page, nth: number = 0) => {
const db = page.getByTestId('dv-table-view').nth(nth);
await db.locator('.data-view-table-group-add-row-button').click();
};
export const switchEdgelessTheme = async (
page: Page,
type: 'system' | 'light' | 'dark'
) => {
await openEditorInfoPanel(page);
const panel = page.getByTestId('info-modal');
await panel.locator(`button[value="${type}"]`).click();
await page.keyboard.press('Escape');
};

View File

@@ -0,0 +1,195 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const getPropertyValueLocator = (page: Page, property: string) => {
return page.locator(
`[data-testid="doc-property-name"]:has-text("${property}") + *`
);
};
export const ensurePagePropertiesVisible = async (page: Page) => {
if (
await page
.getByRole('button', {
name: 'Add property',
})
.isVisible()
) {
return;
}
await page.getByTestId('page-info-collapse').click();
};
export const clickPropertyValue = async (page: Page, property: string) => {
await getPropertyValueLocator(page, property).click();
};
export const openTagsEditor = async (page: Page) => {
await clickPropertyValue(page, 'tags');
await expect(page.getByTestId('tags-editor-popup')).toBeVisible();
};
export const closeTagsEditor = async (page: Page) => {
await page.keyboard.press('Escape');
await expect(page.getByTestId('tags-editor-popup')).not.toBeVisible();
};
export const clickTagFromSelector = async (page: Page, name: string) => {
// assume that the tags editor is already open
await page
.locator(`[data-testid="tag-selector-item"][data-tag-value="${name}"]`)
.click();
};
export const removeSelectedTag = async (page: Page, name: string) => {
await page
.locator(
`[data-testid="tags-editor-popup"] [data-testid="inline-tags-list"] [data-tag-value="${name}"] [data-testid="remove-tag-button"]`
)
.click();
};
export const filterTags = async (page: Page, filter: string) => {
await page
.locator(
'[data-testid="tags-editor-popup"] [data-testid="inline-tags-list"] input'
)
.fill(filter);
};
export const searchAndCreateTag = async (page: Page, name: string) => {
await filterTags(page, name);
await page
.locator(
'[data-testid="tags-editor-popup"] [data-testid="tag-selector-item"]:has-text("Create ")'
)
.click();
};
export const expectTagsVisible = async (
root: Locator | Page,
tags: string[]
) => {
const tagListPanel = root
.getByTestId('property-tags-value')
.getByTestId('inline-tags-list');
expect(await tagListPanel.locator('[data-tag-value]').count()).toBe(
tags.length
);
for (const tag of tags) {
await expect(
tagListPanel.locator(`[data-tag-value="${tag}"]`)
).toBeVisible();
}
};
export const clickAddPropertyButton = async (root: Locator | Page) => {
await root
.getByRole('button', {
name: 'Add property',
})
.click();
};
export const ensureAddPropertyButtonVisible = async (
page: Page,
root: Locator | Page
) => {
if (
await root
.getByRole('button', {
name: 'Add property',
})
.isVisible()
) {
return;
}
await togglePropertyListVisibility(page);
await page.waitForTimeout(500);
await expect(
root.getByRole('button', { name: 'Add property' })
).toBeVisible();
};
export const togglePropertyListVisibility = async (page: Page) => {
await page.getByTestId('property-collapsible-button').click();
};
export const addCustomProperty = async (
page: Page,
root: Locator | Page,
type: string
) => {
await ensureAddPropertyButtonVisible(page, root);
await clickAddPropertyButton(root);
await page
.locator(
`[data-testid="${'create-property-menu-item'}"][data-property-type="${type}"]`
)
.click();
if (await page.getByTestId('edit-property-menu-item').isVisible()) {
// is edit property menu opened, close it
await page.keyboard.press('Escape');
}
await page.waitForTimeout(500);
};
export const expectPropertyOrdering = async (
page: Page,
properties: string[]
) => {
for (let i = 0; i < properties.length; i++) {
await expect(
page.locator(`[data-testid="page-property-row-name"])`).nth(i)
).toHaveText(properties[i]);
}
};
export const openWorkspaceProperties = async (page: Page) => {
await page.getByTestId('slider-bar-workspace-setting-button').click();
await page.getByTestId('workspace-setting:properties').click();
};
export const selectVisibilitySelector = async (
page: Page,
name: string,
option: string
) => {
await page
.getByRole('menu')
.locator(
`[data-testid="page-properties-settings-menu-item"]:has-text("${name}")`
)
.getByRole('button')
.click();
await page
.getByRole('menu')
.last()
.getByRole('menuitem', {
name: option,
exact: true,
})
.click();
await page.waitForTimeout(500);
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
};
export const changePropertyVisibility = async (
page: Page,
name: string,
option: string
) => {
await page
.locator(`[data-testid="doc-property-name"]:has-text("${name}")`)
.click();
await page.locator(`[data-property-visibility="${option}"]`).click();
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
};

View File

@@ -0,0 +1,55 @@
import type { RichText } from '@blocksuite/affine-rich-text';
import type { Page } from '@playwright/test';
export async function setSelection(
page: Page,
anchorBlockId: string,
anchorOffset: number,
focusBlockId: string,
focusOffset: number
) {
await page.evaluate(
({ anchorBlockId, anchorOffset, focusBlockId, focusOffset }) => {
const editorHost = document.querySelector('editor-host');
if (!editorHost) {
throw new Error('Cannot find editor host');
}
const anchorRichText = editorHost.querySelector<RichText>(
`[data-block-id="${anchorBlockId}"] rich-text`
)!;
const anchorRichTextRange = anchorRichText.inlineEditor!.toDomRange({
index: anchorOffset,
length: 0,
})!;
const focusRichText = editorHost.querySelector<RichText>(
`[data-block-id="${focusBlockId}"] rich-text`
)!;
const focusRichTextRange = focusRichText.inlineEditor!.toDomRange({
index: focusOffset,
length: 0,
})!;
const sl = getSelection();
if (!sl) throw new Error('Cannot get selection');
const range = document.createRange();
range.setStart(
anchorRichTextRange.startContainer,
anchorRichTextRange.startOffset
);
range.setEnd(
focusRichTextRange.startContainer,
focusRichTextRange.startOffset
);
sl.removeAllRanges();
sl.addRange(range);
},
{
anchorBlockId,
anchorOffset,
focusBlockId,
focusOffset,
}
);
}

View File

@@ -0,0 +1,63 @@
import { type Page } from '@playwright/test';
export async function clickCollaborationPanel(page: Page) {
await page.click('[data-tab-key="collaboration"]');
}
export async function clickPublishPanel(page: Page) {
await page.click('[data-tab-key="publish"]');
}
export async function openSettingModal(page: Page) {
await page.getByTestId('settings-modal-trigger').click();
}
export async function openAppearancePanel(page: Page) {
await page.getByTestId('appearance-panel-trigger').click();
}
export async function openEditorSetting(page: Page) {
await page.getByTestId('settings-modal-trigger').click();
await page.getByTestId('editor-panel-trigger').click();
}
export async function openShortcutsPanel(page: Page) {
await page.getByTestId('shortcuts-panel-trigger').click();
}
export async function openAboutPanel(page: Page) {
await page.getByTestId('about-panel-trigger').click();
}
export async function openEditorInfoPanel(page: Page) {
await page.getByTestId('header-info-button').click();
}
export async function openExperimentalFeaturesPanel(page: Page) {
await page.getByTestId('experimental-features-trigger').click();
}
export async function confirmExperimentalPrompt(page: Page) {
await page.getByTestId('experimental-prompt-disclaimer').click();
await page.getByTestId('experimental-confirm-button').click();
}
export async function openWorkspaceSettingPanel(page: Page) {
await page
.getByTestId('settings-sidebar')
.getByTestId('workspace-setting:preference')
.click();
}
export async function clickUserInfoCard(page: Page) {
await page.getByTestId('user-info-card').click({
delay: 50,
});
}
export async function closeSettingModal(page: Page) {
await page
.getByTestId('setting-modal')
.getByTestId('modal-close-button')
.click();
}

View File

@@ -0,0 +1,29 @@
import type { Page } from '@playwright/test';
export async function clickSideBarSettingButton(page: Page) {
return page.getByTestId('slider-bar-workspace-setting-button').click();
}
export async function clickSideBarAllPageButton(page: Page) {
return page.getByTestId('all-pages').click();
}
export async function clickSideBarCurrentWorkspaceBanner(page: Page) {
return page.getByTestId('current-workspace-card').click();
}
export async function clickSideBarUseAvatar(page: Page) {
return page.getByTestId('sidebar-user-avatar').click();
}
export async function clickNewPageButton(page: Page) {
return page.getByTestId('sidebar-new-page-button').click();
}
export async function openRightSideBar(
page: Page,
tab?: 'chat' | 'properties' | 'journal' | 'outline' | 'frame'
) {
await page.getByTestId('right-sidebar-toggle').click();
tab && (await page.getByTestId(`sidebar-tab-${tab}`).click());
}

View File

@@ -0,0 +1,24 @@
import type { Page } from '@playwright/test';
export function getDocIdFromUrl(url: string) {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/workspace\/([^/]+)\/([^/]+)\/?/);
if (match && match[2]) {
return match[2];
}
throw new Error('Failed to get doc id from url');
}
export function getCurrentDocIdFromUrl(page: Page) {
return getDocIdFromUrl(page.url());
}
export function getCurrentCollectionIdFromUrl(page: Page) {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(/\/workspace\/([^/]+)\/collection\/([^/]+)\/?/);
if (match && match[2]) {
return match[2];
}
throw new Error('Failed to get collection id from url');
}

View File

@@ -0,0 +1,118 @@
import { setTimeout } from 'node:timers/promises';
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import fs from 'fs-extra';
export async function waitForLogMessage(
page: Page,
log: string
): Promise<boolean> {
return new Promise(resolve => {
page.on('console', msg => {
if (msg.type() === 'log' && msg.text() === log) {
resolve(true);
}
});
});
}
export async function removeWithRetry(
filePath: string,
maxRetries = 5,
delay = 500
) {
for (let i = 0; i < maxRetries; i++) {
try {
await fs.remove(filePath);
return true;
} catch (err: any) {
if (err.code === 'EBUSY' || err.code === 'EPERM') {
await setTimeout(delay);
} else {
console.error(`Failed to delete file ${filePath}:`, err);
}
}
}
// Add a return statement here to ensure that a value is always returned
return false;
}
export async function isContainedInBoundingBox(
container: Locator,
element: Locator,
includeDescendant = false
) {
const containerBox = await container.boundingBox();
if (!containerBox) {
throw new Error('Container bounding box not found');
}
const { x: cx, y: cy, width: cw, height: ch } = containerBox;
const inside = async (el: Locator): Promise<boolean> => {
const elBox = await el.boundingBox();
if (!elBox) {
throw new Error('Element bounding box not found');
}
const { x, y, width, height } = elBox;
return x >= cx && x + width <= cx + cw && y >= cy && y + height <= cy + ch;
};
let isInside = await inside(element);
if (!isInside) return false;
if (includeDescendant) {
const children = await element.locator('*:visible').all();
for (const child of children) {
isInside = await inside(child);
if (!isInside) return false;
}
}
return true;
}
/**
* Click at a specific position relative to a locator's bounding box.
* * Ratios are NOT clamped:
* - 0 ~ 1 : inside the bounding box
* - < 0 : outside (left / top of the box)
* - > 1 : outside (right / bottom of the box)
*
* @param locator The locator to click
* @param options Optional click position ratios
* @param options.xRatio Horizontal ratio relative to box width (not clamped), default is 0.5 (center)
* @param options.yRatio Vertical ratio relative to box height (not clamped), default is 0.5 (center)
*/
export async function clickLocatorByRatio(
page: Page,
locator: Locator,
{ xRatio = 0.5, yRatio = 0.5 } = {}
) {
const box = await getLocatorBox(locator);
await page.mouse.click(
box.x + box.width * xRatio,
box.y + box.height * yRatio
);
}
export async function dblclickLocatorByRatio(
page: Page,
locator: Locator,
{ xRatio = 0.5, yRatio = 0.5 } = {}
) {
const box = await getLocatorBox(locator);
await page.mouse.dblclick(
box.x + box.width * xRatio,
box.y + box.height * yRatio
);
}
async function getLocatorBox(locator: Locator) {
await expect(locator).toBeVisible();
const box = await locator.boundingBox();
if (!box) throw new Error(`error getting locator's bounding box`);
return box;
}

View File

@@ -0,0 +1,57 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { waitForEditorLoad } from './page-logic';
interface CreateWorkspaceParams {
name: string;
}
export async function openWorkspaceListModal(page: Page) {
await page.getByTestId('app-sidebar').getByTestId('workspace-name').click({
delay: 50,
});
}
export async function createLocalWorkspace(
params: CreateWorkspaceParams,
page: Page,
skipOpenWorkspaceListModal = false,
serverId?: string
) {
if (!skipOpenWorkspaceListModal) {
await openWorkspaceListModal(page);
}
// open create workspace modal
await page.getByTestId('new-workspace').click();
// const isDesktop: boolean = await page.evaluate(() => {
// return !!window.appInfo?.electron;
// }, []);
// input workspace name
const workspaceName = page.getByTestId('create-workspace-input');
await workspaceName.click();
await workspaceName.fill(params.name);
await page.getByTestId('server-selector-trigger').click();
const serverSelectorList = page.getByTestId('server-selector-list');
await serverSelectorList.getByTestId(serverId ?? 'local').click();
// click create button
await page.getByTestId('create-workspace-create-button').click({
delay: 500,
});
await expect(
page.getByTestId('create-workspace-create-button')
).not.toBeAttached({ timeout: 30 * 1000 });
await waitForEditorLoad(page);
await expect(page.getByTestId('workspace-name')).toHaveText(params.name);
// if (isDesktop) {
// await page.getByTestId('create-workspace-continue-button').click();
// }
}

14
tests/kit/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.web.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../tools/utils" },
{ "path": "../../blocksuite/affine/all" },
{ "path": "../../packages/common/infra" }
]
}