refactor(electron): fix vitest and add behavior test (#4655)

This commit is contained in:
Alex Yang 2023-10-18 22:14:30 -05:00 committed by GitHub
parent b14a6bc29e
commit 97d8660a54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 256 additions and 349 deletions

View File

@ -128,10 +128,12 @@ jobs:
target: ${{ matrix.spec.target }} target: ${{ matrix.spec.target }}
package: '@affine/native' package: '@affine/native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Run unit tests - name: Run unit tests
if: ${{ matrix.spec.test }} if: ${{ matrix.spec.test }}
shell: bash shell: bash
run: yarn workspace @affine/electron vitest run: yarn vitest
working-directory: packages/frontend/electron
- name: Download core artifact - name: Download core artifact
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3

View File

@ -184,6 +184,13 @@ jobs:
with: with:
electron-install: false electron-install: false
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: '@affine/native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Unit Test - name: Unit Test
run: yarn nx test:coverage @affine/monorepo run: yarn nx test:coverage @affine/monorepo

13
nx.json
View File

@ -35,8 +35,8 @@
"{projectRoot}/storybook-static" "{projectRoot}/storybook-static"
], ],
"inputs": [ "inputs": [
"{workspaceRoot}/infra/**/*", "{workspaceRoot}/packages/frontend/infra/**/*",
"{workspaceRoot}/sdk/**/*", "{workspaceRoot}/packages/frontend/sdk/**/*",
{ {
"runtime": "node -v" "runtime": "node -v"
}, },
@ -80,9 +80,6 @@
"test": { "test": {
"outputs": ["{workspaceRoot}/.nyc_output"], "outputs": ["{workspaceRoot}/.nyc_output"],
"inputs": [ "inputs": [
{
"env": "NATIVE_TEST"
},
{ {
"env": "ENABLE_PRELOADING" "env": "ENABLE_PRELOADING"
}, },
@ -94,9 +91,6 @@
"test:ui": { "test:ui": {
"outputs": ["{workspaceRoot}/.nyc_output"], "outputs": ["{workspaceRoot}/.nyc_output"],
"inputs": [ "inputs": [
{
"env": "NATIVE_TEST"
},
{ {
"env": "ENABLE_PRELOADING" "env": "ENABLE_PRELOADING"
}, },
@ -108,9 +102,6 @@
"test:coverage": { "test:coverage": {
"outputs": ["{workspaceRoot}/.nyc_output"], "outputs": ["{workspaceRoot}/.nyc_output"],
"inputs": [ "inputs": [
{
"env": "NATIVE_TEST"
},
{ {
"env": "ENABLE_PRELOADING" "env": "ENABLE_PRELOADING"
} }

View File

@ -2,8 +2,6 @@
"name": "@affine/env", "name": "@affine/env",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts",
"module": "./src/index.ts",
"devDependencies": { "devDependencies": {
"@blocksuite/global": "0.0.0-20231018100009-361737d3-nightly", "@blocksuite/global": "0.0.0-20231018100009-361737d3-nightly",
"@blocksuite/store": "0.0.0-20231018100009-361737d3-nightly", "@blocksuite/store": "0.0.0-20231018100009-361737d3-nightly",

View File

@ -42,22 +42,6 @@ export type BlockSuiteFeatureFlags = z.infer<typeof blockSuiteFeatureFlags>;
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>; export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
export const platformSchema = z.enum([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd',
]);
export type Platform = z.infer<typeof platformSchema>;
type BrowserBase = { type BrowserBase = {
/** /**
* @example https://app.affine.pro * @example https://app.affine.pro

View File

@ -49,7 +49,7 @@ export const fontStyleOptions = [
}[]; }[];
const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', { const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
clientBorder: environment.isDesktop && globalThis.platform !== 'win32', clientBorder: environment.isDesktop && !environment.isWindows,
fullWidthLayout: false, fullWidthLayout: false,
windowFrameStyle: 'frameless', windowFrameStyle: 'frameless',
fontStyle: 'Sans', fontStyle: 'Sans',

View File

@ -9,22 +9,13 @@ export const rootDir = resolve(electronDir, '..', '..', '..');
export const NODE_MAJOR_VERSION = 18; export const NODE_MAJOR_VERSION = 18;
// hard-coded for now:
// fixme(xp): report error if app is not running on DEV_SERVER_URL
const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
export const mode = (process.env.NODE_ENV = export const mode = (process.env.NODE_ENV =
process.env.NODE_ENV || 'development'); process.env.NODE_ENV || 'development');
export const config = (): BuildOptions => { export const config = (): BuildOptions => {
const define = Object.fromEntries([ const define: Record<string, string> = {};
['process.env.NODE_ENV', `"${mode}"`],
['process.env.USE_WORKER', '"true"'],
]);
if (DEV_SERVER_URL) { define['REPLACE_ME_BUILD_ENV'] = `"${process.env.BUILD_TYPE ?? 'stable'}"`;
define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`;
}
return { return {
entryPoints: [ entryPoints: [
@ -45,11 +36,11 @@ export const config = (): BuildOptions => {
'semver', 'semver',
'tinykeys', 'tinykeys',
], ],
define: define,
format: 'cjs', format: 'cjs',
loader: { loader: {
'.node': 'copy', '.node': 'copy',
}, },
define,
assetNames: '[name]', assetNames: '[name]',
treeShaking: true, treeShaking: true,
sourcemap: 'linked', sourcemap: 'linked',

View File

@ -1,11 +1,10 @@
/* eslint-disable no-async-promise-executor */
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { ChildProcessWithoutNullStreams } from 'child_process'; import type { ChildProcessWithoutNullStreams } from 'child_process';
import electronPath from 'electron'; import type { BuildContext } from 'esbuild';
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
import { config } from './common'; import { config, electronDir } from './common';
// this means we don't spawn electron windows, mainly for testing // this means we don't spawn electron windows, mainly for testing
const watchMode = process.argv.includes('--watch'); const watchMode = process.argv.includes('--watch');
@ -30,7 +29,10 @@ function spawnOrReloadElectron() {
spawnProcess = null; spawnProcess = null;
} }
spawnProcess = spawn(String(electronPath), ['.']); spawnProcess = spawn('electron', ['.'], {
cwd: electronDir,
env: process.env,
});
spawnProcess.stdout.on('data', d => { spawnProcess.stdout.on('data', d => {
const str = d.toString().trim(); const str = d.toString().trim();
@ -38,6 +40,7 @@ function spawnOrReloadElectron() {
console.log(str); console.log(str);
} }
}); });
spawnProcess.stderr.on('data', d => { spawnProcess.stderr.on('data', d => {
const data = d.toString().trim(); const data = d.toString().trim();
if (!data) return; if (!data) return;
@ -47,16 +50,20 @@ function spawnOrReloadElectron() {
}); });
// Stops the watch script when the application has quit // Stops the watch script when the application has quit
spawnProcess.on('exit', process.exit); spawnProcess.on('exit', code => {
if (code && code !== 0) {
console.log(`Electron exited with code ${code}`);
}
process.exit(code ?? 0);
});
} }
const common = config(); const common = config();
async function watchLayers() { async function watchLayers() {
return new Promise<void>(async resolve => { let initialBuild = false;
let initialBuild = false; return new Promise<BuildContext>(resolve => {
const buildContextPromise = esbuild.context({
const buildContext = await esbuild.context({
...common, ...common,
plugins: [ plugins: [
...(common.plugins ?? []), ...(common.plugins ?? []),
@ -68,7 +75,7 @@ async function watchLayers() {
console.log(`[layers] has changed, [re]launching electron...`); console.log(`[layers] has changed, [re]launching electron...`);
spawnOrReloadElectron(); spawnOrReloadElectron();
} else { } else {
resolve(); buildContextPromise.then(resolve);
initialBuild = true; initialBuild = true;
} }
}); });
@ -76,19 +83,18 @@ async function watchLayers() {
}, },
], ],
}); });
await buildContext.watch(); buildContextPromise.then(async buildContext => {
await buildContext.watch();
});
}); });
} }
async function main() { await watchLayers();
await watchLayers();
if (watchMode) { if (watchMode) {
console.log(`Watching for changes...`); console.log(`Watching for changes...`);
} else { } else {
spawnOrReloadElectron(); console.log('Starting electron...');
console.log(`Electron is started, watching for changes...`); spawnOrReloadElectron();
} console.log(`Electron is started, watching for changes...`);
} }
main();

View File

@ -1 +0,0 @@
tmp

View File

@ -1 +0,0 @@
tmp

View File

@ -1,170 +0,0 @@
import assert from 'node:assert';
import path from 'node:path';
import { removeWithRetry } from '@affine-test/kit/utils/utils';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { MainIPCHandlerMap } from '../exposed';
const registeredHandlers = new Map<
string,
((...args: any[]) => Promise<any>)[]
>();
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
? (...args: P) => R
: T;
// common mock dispatcher for ipcMain.handle AND app.on
// alternatively, we can use single parameter for T & F, eg, dispatch('workspace:list'),
// however this is too hard to be typed correctly
async function dispatch<
T extends keyof MainIPCHandlerMap,
F extends keyof MainIPCHandlerMap[T],
>(
namespace: T,
functionName: F,
...args: Parameters<WithoutFirstParameter<MainIPCHandlerMap[T][F]>>
): // @ts-expect-error
ReturnType<MainIPCHandlerMap[T][F]> {
// @ts-expect-error
const handlers = registeredHandlers.get(namespace + ':' + functionName);
assert(handlers);
// we only care about the first handler here
return await handlers[0](null, ...args);
}
const SESSION_DATA_PATH = path.join(__dirname, './tmp', 'affine-test');
const DOCUMENTS_PATH = path.join(__dirname, './tmp', 'affine-test-documents');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (_v: boolean) => {
// will be stubbed later
},
webContents: {
send: (_type: string, ..._args: any[]) => {
// will be stubbed later
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => Promise<any>) => {
const handlers = registeredHandlers.get(key) || [];
handlers.push(callback);
registeredHandlers.set(key, handlers);
},
setMaxListeners: (_n: number) => {
// noop
},
};
const nativeTheme = {
themeSource: 'light',
};
const electronModule = {
app: {
getPath: (name: string) => {
if (name === 'sessionData') {
return SESSION_DATA_PATH;
} else if (name === 'documents') {
return DOCUMENTS_PATH;
}
throw new Error('not implemented');
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
const handlers = registeredHandlers.get(name) || [];
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addListener: (...args: any[]) => {
// @ts-expect-error
electronModule.app.on(...args);
},
removeListener: () => {},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
utilityProcess: {},
nativeTheme: nativeTheme,
ipcMain,
shell: {} as Partial<Electron.Shell>,
dialog: {} as Partial<Electron.Dialog>,
};
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return electronModule;
});
beforeEach(async () => {
const { registerHandlers } = await import('../handlers');
registerHandlers();
// should also register events
const { registerEvents } = await import('../events');
registerEvents();
await fs.mkdirp(SESSION_DATA_PATH);
registeredHandlers.get('ready')?.forEach(fn => fn());
});
afterEach(async () => {
// reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn());
await removeWithRetry(SESSION_DATA_PATH);
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui', 'handleThemeChange', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui', 'handleThemeChange', 'light');
expect(nativeTheme.themeSource).toBe('light');
});
test('sidebar-visibility-change (macOS)', async () => {
vi.stubGlobal('process', { platform: 'darwin' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui', 'handleSidebarVisibilityChange', false);
expect(setWindowButtonVisibility).toBeCalledWith(false);
vi.unstubAllGlobals();
});
test('sidebar-visibility-change (non-macOS)', async () => {
vi.stubGlobal('process', { platform: 'linux' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('applicationMenu', () => {
// test some basic IPC events
test('applicationMenu event', async () => {
const { applicationMenuSubjects } = await import('../application-menu');
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
applicationMenuSubjects.newPageAction.next();
expect(sendStub).toHaveBeenCalledWith(
'applicationMenu:onNewPageAction',
undefined
);
browserWindow.webContents.send = () => {};
});
});

View File

@ -7,11 +7,12 @@ export const ReleaseTypeSchema = z.enum([
'internal', 'internal',
]); ]);
export const envBuildType = ( declare global {
process.env.BUILD_TYPE_OVERRIDE || // THIS variable should be replaced during the build process
process.env.BUILD_TYPE || const REPLACE_ME_BUILD_ENV: string;
'canary' }
)
export const envBuildType = (process.env.BUILD_TYPE || REPLACE_ME_BUILD_ENV)
.trim() .trim()
.toLowerCase(); .toLowerCase();

View File

@ -2,7 +2,6 @@ import { shell } from 'electron';
import log from 'electron-log'; import log from 'electron-log';
export const logger = log.scope('main'); export const logger = log.scope('main');
export const pluginLogger = log.scope('plugin');
log.initialize(); log.initialize();
export function getLogFilePath() { export function getLogFilePath() {

View File

@ -9,7 +9,6 @@ import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('appInfo', appInfo); contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis); contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events); contextBridge.exposeInMainWorld('events', events);
contextBridge.exposeInMainWorld('platform', process.platform);
// Credit to microsoft/vscode // Credit to microsoft/vscode
const globals = { const globals = {

View File

@ -8,7 +8,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp'); const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data'); const appDataPath = path.join(tmpDir, 'app-data');
vi.doMock('../../main-rpc', () => ({ vi.doMock('@affine/electron/helper/main-rpc', () => ({
mainRPC: { mainRPC: {
getPath: async () => appDataPath, getPath: async () => appDataPath,
}, },
@ -22,7 +22,7 @@ function existProcess() {
process.emit('beforeExit', 0); process.emit('beforeExit', 0);
} }
vi.doMock('../secondary-db', () => { vi.doMock('@affine/electron/helper/db/secondary-db', () => {
return { return {
SecondaryWorkspaceSQLiteDB: class { SecondaryWorkspaceSQLiteDB: class {
constructor(...args: any[]) { constructor(...args: any[]) {
@ -49,7 +49,9 @@ afterEach(async () => {
}); });
test('can get a valid WorkspaceSQLiteDB', async () => { test('can get a valid WorkspaceSQLiteDB', async () => {
const { ensureSQLiteDB } = await import('../ensure-db'); const { ensureSQLiteDB } = await import(
'@affine/electron/helper/db/ensure-db'
);
const workspaceId = v4(); const workspaceId = v4();
const db0 = await ensureSQLiteDB(workspaceId); const db0 = await ensureSQLiteDB(workspaceId);
expect(db0).toBeDefined(); expect(db0).toBeDefined();
@ -64,7 +66,9 @@ test('can get a valid WorkspaceSQLiteDB', async () => {
}); });
test('db should be destroyed when app quits', async () => { test('db should be destroyed when app quits', async () => {
const { ensureSQLiteDB } = await import('../ensure-db'); const { ensureSQLiteDB } = await import(
'@affine/electron/helper/db/ensure-db'
);
const workspaceId = v4(); const workspaceId = v4();
const db0 = await ensureSQLiteDB(workspaceId); const db0 = await ensureSQLiteDB(workspaceId);
const db1 = await ensureSQLiteDB(v4()); const db1 = await ensureSQLiteDB(v4());
@ -82,7 +86,9 @@ test('db should be destroyed when app quits', async () => {
}); });
test('db should be removed in db$Map after destroyed', async () => { test('db should be removed in db$Map after destroyed', async () => {
const { ensureSQLiteDB, db$Map } = await import('../ensure-db'); const { ensureSQLiteDB, db$Map } = await import(
'@affine/electron/helper/db/ensure-db'
);
const workspaceId = v4(); const workspaceId = v4();
const db = await ensureSQLiteDB(workspaceId); const db = await ensureSQLiteDB(workspaceId);
await db.destroy(); await db.destroy();
@ -92,8 +98,12 @@ test('db should be removed in db$Map after destroyed', async () => {
// we have removed secondary db feature // we have removed secondary db feature
test.skip('if db has a secondary db path, we should also poll that', async () => { test.skip('if db has a secondary db path, we should also poll that', async () => {
const { ensureSQLiteDB } = await import('../ensure-db'); const { ensureSQLiteDB } = await import(
const { storeWorkspaceMeta } = await import('../../workspace'); '@affine/electron/helper/db/ensure-db'
);
const { storeWorkspaceMeta } = await import(
'@affine/electron/helper/workspace'
);
const workspaceId = v4(); const workspaceId = v4();
await storeWorkspaceMeta(workspaceId, { await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary.db'), secondaryDBPath: path.join(tmpDir, 'secondary.db'),

View File

@ -1,18 +1,20 @@
import path from 'node:path'; import path from 'node:path';
import {
copyToTemp,
migrateToSubdocAndReplaceDatabase,
} from '@affine/electron/helper/db/migration';
import { SqliteConnection } from '@affine/native'; import { SqliteConnection } from '@affine/native';
import { removeWithRetry } from '@affine-test/kit/utils/utils'; import { removeWithRetry } from '@affine-test/kit/utils/utils';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyUpdate, Doc as YDoc } from 'yjs'; import { applyUpdate, Doc as YDoc } from 'yjs';
import { copyToTemp, migrateToSubdocAndReplaceDatabase } from '../migration';
const tmpDir = path.join(__dirname, 'tmp'); const tmpDir = path.join(__dirname, 'tmp');
const testDBFilePath = path.resolve(__dirname, 'old-db.affine'); const testDBFilePath = path.resolve(__dirname, 'old-db.affine');
const appDataPath = path.join(tmpDir, 'app-data'); const appDataPath = path.join(tmpDir, 'app-data');
vi.mock('../../main-rpc', () => ({ vi.mock('@affine/electron/helper/main-rpc', () => ({
mainRPC: { mainRPC: {
getPath: async () => appDataPath, getPath: async () => appDataPath,
}, },

View File

@ -1,17 +1,16 @@
import path from 'node:path'; import path from 'node:path';
import { dbSubjects } from '@affine/electron/helper/db/subjects';
import { removeWithRetry } from '@affine-test/kit/utils/utils'; import { removeWithRetry } from '@affine-test/kit/utils/utils';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs'; import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { dbSubjects } from '../subjects';
const tmpDir = path.join(__dirname, 'tmp'); const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data'); const appDataPath = path.join(tmpDir, 'app-data');
vi.doMock('../../main-rpc', () => ({ vi.doMock('@affine/electron/helper/main-rpc', () => ({
mainRPC: { mainRPC: {
getPath: async () => appDataPath, getPath: async () => appDataPath,
}, },
@ -47,7 +46,9 @@ function getTestSubDocUpdates() {
} }
test('can create new db file if not exists', async () => { test('can create new db file if not exists', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4(); const workspaceId = v4();
const db = await openWorkspaceDatabase(workspaceId); const db = await openWorkspaceDatabase(workspaceId);
const dbPath = path.join( const dbPath = path.join(
@ -60,7 +61,9 @@ test('can create new db file if not exists', async () => {
}); });
test('on applyUpdate (from self), will not trigger update', async () => { test('on applyUpdate (from self), will not trigger update', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4(); const workspaceId = v4();
const onUpdate = vi.fn(); const onUpdate = vi.fn();
@ -72,7 +75,9 @@ test('on applyUpdate (from self), will not trigger update', async () => {
}); });
test('on applyUpdate (from renderer), will trigger update', async () => { test('on applyUpdate (from renderer), will trigger update', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4(); const workspaceId = v4();
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const onExternalUpdate = vi.fn(); const onExternalUpdate = vi.fn();
@ -87,7 +92,9 @@ test('on applyUpdate (from renderer), will trigger update', async () => {
}); });
test('on applyUpdate (from renderer, subdoc), will trigger update', async () => { test('on applyUpdate (from renderer, subdoc), will trigger update', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4(); const workspaceId = v4();
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const insertUpdates = vi.fn(); const insertUpdates = vi.fn();
@ -112,7 +119,9 @@ test('on applyUpdate (from renderer, subdoc), will trigger update', async () =>
}); });
test('on applyUpdate (from external), will trigger update & send external update event', async () => { test('on applyUpdate (from external), will trigger update & send external update event', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4(); const workspaceId = v4();
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const onExternalUpdate = vi.fn(); const onExternalUpdate = vi.fn();
@ -128,7 +137,9 @@ test('on applyUpdate (from external), will trigger update & send external update
}); });
test('on destroy, check if resources have been released', async () => { test('on destroy, check if resources have been released', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4(); const workspaceId = v4();
const db = await openWorkspaceDatabase(workspaceId); const db = await openWorkspaceDatabase(workspaceId);
const updateSub = { const updateSub = {

View File

@ -8,13 +8,13 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp'); const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data'); const appDataPath = path.join(tmpDir, 'app-data');
vi.doMock('../../db/ensure-db', () => ({ vi.doMock('@affine/electron/helper/db/ensure-db', () => ({
ensureSQLiteDB: async () => ({ ensureSQLiteDB: async () => ({
destroy: () => {}, destroy: () => {},
}), }),
})); }));
vi.doMock('../../main-rpc', () => ({ vi.doMock('@affine/electron/helper/main-rpc', () => ({
mainRPC: { mainRPC: {
getPath: async () => appDataPath, getPath: async () => appDataPath,
}, },
@ -26,7 +26,9 @@ afterEach(async () => {
describe('list workspaces', () => { describe('list workspaces', () => {
test('listWorkspaces (valid)', async () => { test('listWorkspaces (valid)', async () => {
const { listWorkspaces } = await import('../handlers'); const { listWorkspaces } = await import(
'@affine/electron/helper/workspace/handlers'
);
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
const meta = { const meta = {
@ -39,7 +41,9 @@ describe('list workspaces', () => {
}); });
test('listWorkspaces (without meta json file)', async () => { test('listWorkspaces (without meta json file)', async () => {
const { listWorkspaces } = await import('../handlers'); const { listWorkspaces } = await import(
'@affine/electron/helper/workspace/handlers'
);
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
@ -56,7 +60,9 @@ describe('list workspaces', () => {
describe('delete workspace', () => { describe('delete workspace', () => {
test('deleteWorkspace', async () => { test('deleteWorkspace', async () => {
const { deleteWorkspace } = await import('../handlers'); const { deleteWorkspace } = await import(
'@affine/electron/helper/workspace/handlers'
);
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
@ -73,7 +79,9 @@ describe('delete workspace', () => {
describe('getWorkspaceMeta', () => { describe('getWorkspaceMeta', () => {
test('can get meta', async () => { test('can get meta', async () => {
const { getWorkspaceMeta } = await import('../meta'); const { getWorkspaceMeta } = await import(
'@affine/electron/helper/workspace/meta'
);
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
const meta = { const meta = {
@ -85,7 +93,9 @@ describe('getWorkspaceMeta', () => {
}); });
test('can create meta if not exists', async () => { test('can create meta if not exists', async () => {
const { getWorkspaceMeta } = await import('../meta'); const { getWorkspaceMeta } = await import(
'@affine/electron/helper/workspace/meta'
);
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
@ -99,7 +109,9 @@ describe('getWorkspaceMeta', () => {
}); });
test('can migrate meta if db file is a link', async () => { test('can migrate meta if db file is a link', async () => {
const { getWorkspaceMeta } = await import('../meta'); const { getWorkspaceMeta } = await import(
'@affine/electron/helper/workspace/meta'
);
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
@ -121,7 +133,9 @@ describe('getWorkspaceMeta', () => {
}); });
test('storeWorkspaceMeta', async () => { test('storeWorkspaceMeta', async () => {
const { storeWorkspaceMeta } = await import('../handlers'); const { storeWorkspaceMeta } = await import(
'@affine/electron/helper/workspace/handlers'
);
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);

View File

@ -28,12 +28,12 @@
{ {
"path": "../../common/env" "path": "../../common/env"
}, },
// Tests
{ {
"path": "./tsconfig.node.json" "path": "./tsconfig.node.json"
}, },
{ "path": "../../../tests/kit" } {
"path": "../../../tests/kit"
}
], ],
"ts-node": { "ts-node": {
"esm": true, "esm": true,

View File

@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"resolveJsonModule": true,
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"noEmit": false,
"outDir": "./lib/tests",
"types": ["node"],
"allowJs": true
},
"references": [
{
"path": "./tsconfig.json"
}
],
"include": ["./test"]
}

View File

@ -1,12 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": true
},
"include": ["**/__tests__/**/*", "./tests"],
"references": [
{
"path": "./tsconfig.json"
}
]
}

View File

@ -1,19 +0,0 @@
/**
* Describes all existing environment variables and their types.
* Required for Code completion and type checking
*
* Note: To prevent accidentally leaking env variables to the client, only variables prefixed with `VITE_` are exposed to your Vite-processed code
*
* @see https://github.com/vitejs/vite/blob/cab55b32de62e0de7d7789e8c2a1f04a8eae3a3f/packages/vite/types/importMeta.d.ts#L62-L69 Base Interface
* @see https://vitejs.dev/guide/env-and-mode.html#env-files Vite Env Variables Doc
*/
interface ImportMetaEnv {
/**
* The value of the variable is set in scripts/watch.js and depend on layers/main/vite.config.js
*/
readonly DEV_SERVER_URL: undefined | string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -4,24 +4,17 @@ import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const rootDir = fileURLToPath(new URL('../../..', import.meta.url)); const rootDir = fileURLToPath(new URL('../../..', import.meta.url));
const pluginOutputDir = resolve(
rootDir,
'./packages/frontend/electron/dist/plugins'
);
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
// prevent tests using two different sources of yjs // prevent tests using two different sources of yjs
yjs: resolve(rootDir, 'node_modules/yjs'), yjs: resolve(rootDir, 'node_modules/yjs'),
'@affine/electron': resolve(rootDir, 'packages/frontend/electron/src'),
}, },
}, },
define: {
'process.env.PLUGIN_DIR': JSON.stringify(pluginOutputDir),
},
test: { test: {
include: ['./src/**/*.spec.ts'], include: ['./test/**/*.spec.ts'],
exclude: ['**/node_modules', '**/dist', '**/build', '**/out'],
testTimeout: 5000, testTimeout: 5000,
singleThread: true, singleThread: true,
threads: false, threads: false,

View File

@ -112,7 +112,7 @@ const fetchMetadata: FetchMetadata = async (get, { signal }) => {
// migration step, only data in `METADATA_STORAGE_KEY` will be migrated // migration step, only data in `METADATA_STORAGE_KEY` will be migrated
if ( if (
maybeMetadata.some(meta => !('version' in meta)) && maybeMetadata.some(meta => !('version' in meta)) &&
!globalThis.$migrationDone !window.$migrationDone
) { ) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => reject(), { once: true }); signal.addEventListener('abort', () => reject(), { once: true });

View File

@ -1,18 +0,0 @@
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runCli } from '@magic-works/i18n-codegen';
import { beforeAll } from 'vitest';
beforeAll(async () => {
runCli(
{
watch: false,
cwd: join(fileURLToPath(import.meta.url), '../../../.i18n-codegen.json'),
},
error => {
console.error(error);
process.exit(1);
}
);
});

View File

@ -1,5 +1,3 @@
// import { platform } from 'node:os';
import { test } from '@affine-test/kit/electron'; import { test } from '@affine-test/kit/electron';
import { withCtrlOrMeta } from '@affine-test/kit/utils/keyboard'; import { withCtrlOrMeta } from '@affine-test/kit/utils/keyboard';
import { getBlockSuiteEditorTitle } from '@affine-test/kit/utils/page-logic'; import { getBlockSuiteEditorTitle } from '@affine-test/kit/utils/page-logic';

View File

@ -0,0 +1,38 @@
import os from 'node:os';
import { test } from '@affine-test/kit/electron';
import { shouldCallIpcRendererHandler } from '@affine-test/kit/utils/ipc';
test.describe('behavior test', () => {
if (os.platform() === 'darwin') {
test('system button should hidden correctly', async ({
page,
electronApp,
}) => {
{
const promise = shouldCallIpcRendererHandler(
electronApp,
'ui:handleSidebarVisibilityChange'
);
await page
.locator(
'[data-testid=app-sidebar-arrow-button-collapse][data-show=true]'
)
.click();
await promise;
}
{
const promise = shouldCallIpcRendererHandler(
electronApp,
'ui:handleSidebarVisibilityChange'
);
await page
.locator(
'[data-testid=app-sidebar-arrow-button-expand][data-show=true]'
)
.click();
await promise;
}
});
}
});

55
tests/kit/utils/ipc.ts Normal file
View File

@ -0,0 +1,55 @@
// Credit: https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/ipc_helpers.ts
import type { Page } from '@playwright/test';
import type { ElectronApplication } from 'playwright';
export function ipcRendererInvoke(page: Page, channel: string, ...args: any[]) {
return page.evaluate(
({ channel, args }) => {
return window.affine.ipcRenderer.invoke(channel, ...args);
},
{ channel, args }
);
}
export function ipcRendererSend(page: Page, channel: string, ...args: any[]) {
return page.evaluate(
({ channel, args }) => {
window.affine.ipcRenderer.send(channel, ...args);
},
{ channel, args }
);
}
type IpcMainWithHandlers = Electron.IpcMain & {
_invokeHandlers: Map<
string,
(e: Electron.IpcMainInvokeEvent, ...args: unknown[]) => Promise<unknown>
>;
};
export function shouldCallIpcRendererHandler(
electronApp: ElectronApplication,
channel: string
) {
return electronApp.evaluate(
async ({ ipcMain }, { channel }) => {
const ipcMainWH = ipcMain as IpcMainWithHandlers;
// this is all a bit of a hack, so let's test as we go
if (!ipcMainWH._invokeHandlers) {
throw new Error(`Cannot access ipcMain._invokeHandlers`);
}
const handler = ipcMainWH._invokeHandlers.get(channel);
if (!handler) {
throw new Error(`No ipcMain handler registered for '${channel}'`);
}
return new Promise<void>(resolve => {
ipcMainWH._invokeHandlers.set(channel, async (e, ...args) => {
ipcMainWH._invokeHandlers.set(channel, handler);
resolve();
return handler(e, ...args);
});
});
},
{ channel }
);
}

View File

@ -1,4 +1,4 @@
import type { Environment, Platform, RuntimeConfig } from '@affine/env/global'; import type { Environment, RuntimeConfig } from '@affine/env/global';
import type { import type {
DBHandlerManager, DBHandlerManager,
DebugHandlerManager, DebugHandlerManager,
@ -26,6 +26,25 @@ declare global {
workspace: UnwrapManagerHandlerToClientSide<WorkspaceHandlerManager>; workspace: UnwrapManagerHandlerToClientSide<WorkspaceHandlerManager>;
}; };
events: EventMap; events: EventMap;
affine: {
ipcRenderer: {
send(channel: string, ...args: any[]): void;
invoke(channel: string, ...args: any[]): Promise<any>;
on(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
): this;
once(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
): this;
removeListener(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
): this;
};
};
$migrationDone: boolean | undefined;
} }
interface WindowEventMap { interface WindowEventMap {
@ -37,21 +56,11 @@ declare global {
env: Record<string, string>; env: Record<string, string>;
}; };
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
var $migrationDone: boolean;
// eslint-disable-next-line no-var
var platform: Platform | undefined;
// eslint-disable-next-line no-var
var environment: Environment; var environment: Environment;
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
var runtimeConfig: RuntimeConfig; var runtimeConfig: RuntimeConfig;
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
var $AFFINE_SETUP: boolean | undefined; var $AFFINE_SETUP: boolean | undefined;
// eslint-disable-next-line no-var
var editorVersion: string | undefined;
// eslint-disable-next-line no-var
var prefixUrl: string;
// eslint-disable-next-line no-var
var websocketPrefixUrl: string;
} }
declare module '@blocksuite/store' { declare module '@blocksuite/store' {

View File

@ -103,7 +103,7 @@
"path": "./packages/frontend/core" "path": "./packages/frontend/core"
}, },
{ {
"path": "./packages/frontend/electron" "path": "./packages/frontend/electron/tsconfig.test.json"
}, },
{ {
"path": "./packages/frontend/graphql" "path": "./packages/frontend/graphql"

View File

@ -19,7 +19,6 @@ export default defineConfig({
test: { test: {
setupFiles: [ setupFiles: [
resolve(rootDir, './scripts/setup/lit.ts'), resolve(rootDir, './scripts/setup/lit.ts'),
resolve(rootDir, './scripts/setup/i18n.ts'),
resolve(rootDir, './scripts/setup/lottie-web.ts'), resolve(rootDir, './scripts/setup/lottie-web.ts'),
resolve(rootDir, './scripts/setup/global.ts'), resolve(rootDir, './scripts/setup/global.ts'),
], ],

1
vitest.workspace.ts Normal file
View File

@ -0,0 +1 @@
export default ['.', './packages/frontend/electron'];