feat(electron): multi tabs support (#7440)

use https://www.electronjs.org/docs/latest/api/web-contents-view to serve different tab views
added tabs view manager in electron to handle multi-view actions and events.

fix AF-1111
fix AF-999
fix PD-1459
fix AF-964
PD-1458
This commit is contained in:
pengx17
2024-07-29 11:05:22 +00:00
parent 622715d2f3
commit 1efc1d0f5b
88 changed files with 3160 additions and 945 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@affine-test/affine-desktop-cloud",
"private": true,
"scripts": {
"e2e": "DEBUG=pw:browser yarn playwright test"
"e2e": "yarn playwright test"
},
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
+33 -57
View File
@@ -1,10 +1,8 @@
import { test } from '@affine-test/kit/electron';
import { withCtrlOrMeta } from '@affine-test/kit/utils/keyboard';
import {
clickNewPageButton,
createLinkedPage,
getBlockSuiteEditorTitle,
waitForEmptyEditor,
} from '@affine-test/kit/utils/page-logic';
import {
clickSideBarCurrentWorkspaceBanner,
@@ -14,73 +12,53 @@ import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
const historyShortcut = async (page: Page, command: 'goBack' | 'goForward') => {
await withCtrlOrMeta(page, () =>
page.keyboard.press(command === 'goBack' ? '[' : ']', { delay: 50 })
await page.keyboard.press(
command === 'goBack' ? 'ControlOrMeta+[' : 'ControlOrMeta+]'
);
};
test('new page', async ({ page, workspace }) => {
await page.getByTestId('sidebar-new-page-button').click();
await page.waitForSelector('v-line');
await clickNewPageButton(page);
const flavour = (await workspace.current()).meta.flavour;
expect(flavour).toBe('local');
});
test('app sidebar router forward/back', async ({ page }) => {
{
// create pages
await page.waitForTimeout(500);
await clickNewPageButton(page);
await page.waitForSelector('v-line');
const title = getBlockSuiteEditorTitle(page);
await title.focus();
await title.pressSequentially('test1', {
delay: 100,
});
await page.waitForTimeout(500);
await page.getByTestId('sidebar-new-page-button').click({
delay: 100,
});
await page.waitForSelector('v-line');
// create pages
await page.waitForTimeout(500);
await clickNewPageButton(page);
const title = getBlockSuiteEditorTitle(page);
await title.focus();
await title.pressSequentially('test1', {
delay: 100,
});
await page.waitForTimeout(500);
await clickNewPageButton(page);
await title.focus();
await title.pressSequentially('test2', {
delay: 100,
});
await page.waitForTimeout(500);
await page.getByTestId('sidebar-new-page-button').click({
delay: 100,
});
await page.waitForSelector('v-line');
await title.focus();
await title.pressSequentially('test3', {
delay: 100,
});
}
{
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
}
await title.focus();
await title.pressSequentially('test2', {
delay: 100,
});
await page.waitForTimeout(500);
await clickNewPageButton(page);
await title.focus();
await title.pressSequentially('test3', {
delay: 100,
});
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
await page.click('[data-testid="app-navigation-button-back"]');
await page.click('[data-testid="app-navigation-button-back"]');
{
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
}
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
await page.click('[data-testid="app-navigation-button-forward"]');
await page.click('[data-testid="app-navigation-button-forward"]');
{
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
}
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
await historyShortcut(page, 'goBack');
await historyShortcut(page, 'goBack');
{
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
}
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
await historyShortcut(page, 'goForward');
await historyShortcut(page, 'goForward');
{
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
}
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
});
test('clientBorder value should disable by default on window', async ({
@@ -133,6 +111,8 @@ test('windows only check', async ({ page }) => {
});
test('delete workspace', async ({ page }) => {
await clickNewPageButton(page);
await clickSideBarCurrentWorkspaceBanner(page);
await page.getByTestId('new-workspace').click();
await page.getByTestId('create-workspace-input').fill('Delete Me');
@@ -159,8 +139,7 @@ test('delete workspace', async ({ page }) => {
await page.getByTestId('delete-workspace-button').click();
await page.getByTestId('delete-workspace-input').fill('Delete Me');
await page.getByTestId('delete-workspace-confirm-button').click();
await page.waitForTimeout(1000);
expect(await page.getByTestId('workspace-name').textContent()).toBe(
await expect(page.getByTestId('workspace-name')).toContainText(
'Demo Workspace'
);
});
@@ -192,17 +171,14 @@ async function enableSplitView(page: Page) {
test('open split view', async ({ page }) => {
await enableSplitView(page);
await page.getByTestId('sidebar-new-page-button').click({
delay: 100,
});
await waitForEmptyEditor(page);
await clickNewPageButton(page);
await page.waitForTimeout(500);
await page.keyboard.press('Enter');
await createLinkedPage(page, 'hi from another page');
await page
.locator('.affine-reference-title:has-text("hi from another page")')
.click({
modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'],
modifiers: ['ControlOrMeta', 'Alt'],
});
await expect(page.locator('.doc-title-container')).toHaveCount(2);
});
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@affine-test/affine-desktop",
"private": true,
"scripts": {
"e2e": "DEBUG=\"pw:*\" yarn playwright test"
"e2e": "yarn playwright test"
},
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
@@ -16,6 +16,7 @@ import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
testDir: './e2e',
fullyParallel: true,
workers: 2,
timeout: process.env.CI ? 50_000 : 30_000,
outputDir: testResultDir,
use: {
+101 -78
View File
@@ -1,17 +1,12 @@
import crypto from 'node:crypto';
import { join, resolve } from 'node:path';
import type { Page } from '@playwright/test';
import { expect, type Page } from '@playwright/test';
import fs from 'fs-extra';
import type { ElectronApplication } from 'playwright';
import { _electron as electron } from 'playwright';
import {
enableCoverage,
istanbulTempDir,
test as base,
testResultDir,
} from './playwright';
import { test as base, testResultDir } from './playwright';
import { removeWithRetry } from './utils/utils';
const projectRoot = join(__dirname, '..', '..');
@@ -23,8 +18,30 @@ function generateUUID() {
type RoutePath = 'setting';
const getPageId = async (page: Page) => {
return page.evaluate(() => {
return (window.appInfo as any)?.viewId as string;
});
};
const isActivePage = async (page: Page) => {
return page.evaluate(async () => {
return await (window as any).apis?.ui.isActiveTab();
});
};
const getActivePage = async (pages: Page[]) => {
for (const page of pages) {
if (await isActivePage(page)) {
return page;
}
}
return null;
};
export const test = base.extend<{
electronApp: ElectronApplication;
shell: Page;
appInfo: {
appPath: string;
appData: string;
@@ -34,88 +51,89 @@ export const test = base.extend<{
goto: (path: RoutePath) => Promise<void>;
};
}>({
shell: async ({ electronApp }, use) => {
await expect.poll(() => electronApp.windows().length > 1).toBeTruthy();
for (const page of electronApp.windows()) {
const viewId = await getPageId(page);
if (viewId === 'shell') {
await use(page);
break;
}
}
},
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await expect
.poll(() => {
return electronApp.windows().length > 1;
})
.toBeTruthy();
const page = await getActivePage(electronApp.windows());
if (!page) {
throw new Error('No active page found');
}
// wait for blocksuite to be loaded
await page.waitForSelector('v-line');
await page.evaluate(() => {
window.localStorage.setItem('dismissAiOnboarding', 'true');
window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
});
// wait for blocksuite to be loaded
await page.waitForSelector('v-line');
if (enableCoverage) {
await fs.promises.mkdir(istanbulTempDir, { recursive: true });
await page.exposeFunction(
'collectIstanbulCoverage',
(coverageJSON?: string) => {
if (coverageJSON)
fs.writeFileSync(
join(
istanbulTempDir,
`playwright_coverage_${generateUUID()}.json`
),
coverageJSON
);
}
);
}
await page.reload();
await use(page as Page);
if (enableCoverage) {
await page.evaluate(() =>
// @ts-expect-error
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))
);
}
await page.close();
},
// eslint-disable-next-line no-empty-pattern
electronApp: async ({}, use) => {
// a random id to avoid conflicts between tests
const id = generateUUID();
const ext = process.platform === 'win32' ? '.cmd' : '';
const dist = resolve(electronRoot, 'dist');
const clonedDist = resolve(electronRoot, 'e2e-dist-' + id);
await fs.copy(dist, clonedDist);
const packageJson = await fs.readJSON(
resolve(electronRoot, 'package.json')
);
// overwrite the app name
packageJson.name = 'affine-test-' + id;
// overwrite the path to the main script
packageJson.main = './main.js';
// write to the cloned dist
await fs.writeJSON(resolve(clonedDist, 'package.json'), packageJson);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value) {
env[key] = value;
}
}
if (process.env.DEV_SERVER_URL) {
env.DEV_SERVER_URL = process.env.DEV_SERVER_URL;
}
env.SKIP_ONBOARDING = '1';
const electronApp = await electron.launch({
args: [clonedDist],
env,
executablePath: resolve(
projectRoot,
'node_modules',
'.bin',
`electron${ext}`
),
cwd: clonedDist,
recordVideo: {
dir: testResultDir,
},
colorScheme: 'light',
});
await use(electronApp);
try {
// a random id to avoid conflicts between tests
const id = generateUUID();
const dist = resolve(electronRoot, 'dist');
const clonedDist = resolve(electronRoot, 'e2e-dist-' + id);
await fs.copy(dist, clonedDist);
const packageJson = await fs.readJSON(
resolve(electronRoot, 'package.json')
);
// overwrite the app name
packageJson.name = 'affine-test-' + id;
// overwrite the path to the main script
packageJson.main = './main.js';
// write to the cloned dist
await fs.writeJSON(resolve(clonedDist, 'package.json'), packageJson);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value) {
env[key] = value;
}
}
env.SKIP_ONBOARDING = '1';
const electronApp = await electron.launch({
args: [clonedDist],
env,
cwd: clonedDist,
recordVideo: {
dir: testResultDir,
},
colorScheme: 'light',
});
await use(electronApp);
console.log('Cleaning up...');
const pages = electronApp.windows();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
await page.close();
console.log(`Closed page ${i + 1}/${pages.length}`);
}
await electronApp.close();
await removeWithRetry(clonedDist);
} catch (error) {
console.log(error);
@@ -132,3 +150,8 @@ export const test = base.extend<{
await use(appInfo);
},
});
// eslint-disable-next-line no-empty-pattern
test.afterEach(({}, testInfo) => {
console.log('cleaning up for ' + testInfo);
});