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
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:
27
tests/kit/package.json
Normal file
27
tests/kit/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
17
tests/kit/src/bs/linked-toolbar.ts
Normal file
17
tests/kit/src/bs/linked-toolbar.ts
Normal 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
7
tests/kit/src/bs/misc.ts
Normal 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
53
tests/kit/src/bs/table.ts
Normal 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
428
tests/kit/src/electron.ts
Normal 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
27
tests/kit/src/mobile.ts
Normal 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
120
tests/kit/src/playwright.ts
Normal 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__))
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
54
tests/kit/src/utils/app-tabs.ts
Normal file
54
tests/kit/src/utils/app-tabs.ts
Normal 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();
|
||||
}
|
||||
31
tests/kit/src/utils/attachment.ts
Normal file
31
tests/kit/src/utils/attachment.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
24
tests/kit/src/utils/clipboard.ts
Normal file
24
tests/kit/src/utils/clipboard.ts
Normal 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);
|
||||
}
|
||||
366
tests/kit/src/utils/cloud.ts
Normal file
366
tests/kit/src/utils/cloud.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
35
tests/kit/src/utils/drop-file.ts
Normal file
35
tests/kit/src/utils/drop-file.ts
Normal 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 });
|
||||
};
|
||||
231
tests/kit/src/utils/edgeless-perf.ts
Normal file
231
tests/kit/src/utils/edgeless-perf.ts
Normal 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);
|
||||
}
|
||||
554
tests/kit/src/utils/editor.ts
Normal file
554
tests/kit/src/utils/editor.ts
Normal 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);
|
||||
}
|
||||
13
tests/kit/src/utils/filter.ts
Normal file
13
tests/kit/src/utils/filter.ts
Normal 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 });
|
||||
}
|
||||
23
tests/kit/src/utils/image.ts
Normal file
23
tests/kit/src/utils/image.ts
Normal 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);
|
||||
}
|
||||
136
tests/kit/src/utils/keyboard.ts
Normal file
136
tests/kit/src/utils/keyboard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
tests/kit/src/utils/load-page.ts
Normal file
29
tests/kit/src/utils/load-page.ts
Normal 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();
|
||||
}
|
||||
266
tests/kit/src/utils/page-logic.ts
Normal file
266
tests/kit/src/utils/page-logic.ts
Normal 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');
|
||||
};
|
||||
195
tests/kit/src/utils/properties.ts
Normal file
195
tests/kit/src/utils/properties.ts
Normal 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);
|
||||
};
|
||||
55
tests/kit/src/utils/selection.ts
Normal file
55
tests/kit/src/utils/selection.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
63
tests/kit/src/utils/setting.ts
Normal file
63
tests/kit/src/utils/setting.ts
Normal 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();
|
||||
}
|
||||
29
tests/kit/src/utils/sidebar.ts
Normal file
29
tests/kit/src/utils/sidebar.ts
Normal 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());
|
||||
}
|
||||
24
tests/kit/src/utils/url.ts
Normal file
24
tests/kit/src/utils/url.ts
Normal 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');
|
||||
}
|
||||
118
tests/kit/src/utils/utils.ts
Normal file
118
tests/kit/src/utils/utils.ts
Normal 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;
|
||||
}
|
||||
57
tests/kit/src/utils/workspace.ts
Normal file
57
tests/kit/src/utils/workspace.ts
Normal 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
14
tests/kit/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user