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
35 changed files with 256 additions and 349 deletions
+3 -12
View File
@@ -9,22 +9,13 @@ export const rootDir = resolve(electronDir, '..', '..', '..');
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 =
process.env.NODE_ENV || 'development');
export const config = (): BuildOptions => {
const define = Object.fromEntries([
['process.env.NODE_ENV', `"${mode}"`],
['process.env.USE_WORKER', '"true"'],
]);
const define: Record<string, string> = {};
if (DEV_SERVER_URL) {
define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`;
}
define['REPLACE_ME_BUILD_ENV'] = `"${process.env.BUILD_TYPE ?? 'stable'}"`;
return {
entryPoints: [
@@ -45,11 +36,11 @@ export const config = (): BuildOptions => {
'semver',
'tinykeys',
],
define: define,
format: 'cjs',
loader: {
'.node': 'copy',
},
define,
assetNames: '[name]',
treeShaking: true,
sourcemap: 'linked',
+27 -21
View File
@@ -1,11 +1,10 @@
/* eslint-disable no-async-promise-executor */
import { spawn } from 'node:child_process';
import type { ChildProcessWithoutNullStreams } from 'child_process';
import electronPath from 'electron';
import type { BuildContext } 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
const watchMode = process.argv.includes('--watch');
@@ -30,7 +29,10 @@ function spawnOrReloadElectron() {
spawnProcess = null;
}
spawnProcess = spawn(String(electronPath), ['.']);
spawnProcess = spawn('electron', ['.'], {
cwd: electronDir,
env: process.env,
});
spawnProcess.stdout.on('data', d => {
const str = d.toString().trim();
@@ -38,6 +40,7 @@ function spawnOrReloadElectron() {
console.log(str);
}
});
spawnProcess.stderr.on('data', d => {
const data = d.toString().trim();
if (!data) return;
@@ -47,16 +50,20 @@ function spawnOrReloadElectron() {
});
// 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();
async function watchLayers() {
return new Promise<void>(async resolve => {
let initialBuild = false;
const buildContext = await esbuild.context({
let initialBuild = false;
return new Promise<BuildContext>(resolve => {
const buildContextPromise = esbuild.context({
...common,
plugins: [
...(common.plugins ?? []),
@@ -68,7 +75,7 @@ async function watchLayers() {
console.log(`[layers] has changed, [re]launching electron...`);
spawnOrReloadElectron();
} else {
resolve();
buildContextPromise.then(resolve);
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) {
console.log(`Watching for changes...`);
} else {
spawnOrReloadElectron();
console.log(`Electron is started, watching for changes...`);
}
if (watchMode) {
console.log(`Watching for changes...`);
} else {
console.log('Starting electron...');
spawnOrReloadElectron();
console.log(`Electron is started, watching for changes...`);
}
main();
@@ -1 +0,0 @@
tmp
@@ -1 +0,0 @@
tmp
@@ -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 = () => {};
});
});
@@ -7,11 +7,12 @@ export const ReleaseTypeSchema = z.enum([
'internal',
]);
export const envBuildType = (
process.env.BUILD_TYPE_OVERRIDE ||
process.env.BUILD_TYPE ||
'canary'
)
declare global {
// THIS variable should be replaced during the build process
const REPLACE_ME_BUILD_ENV: string;
}
export const envBuildType = (process.env.BUILD_TYPE || REPLACE_ME_BUILD_ENV)
.trim()
.toLowerCase();
@@ -2,7 +2,6 @@ import { shell } from 'electron';
import log from 'electron-log';
export const logger = log.scope('main');
export const pluginLogger = log.scope('plugin');
log.initialize();
export function getLogFilePath() {
@@ -9,7 +9,6 @@ import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events);
contextBridge.exposeInMainWorld('platform', process.platform);
// Credit to microsoft/vscode
const globals = {
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
vi.doMock('../../main-rpc', () => ({
vi.doMock('@affine/electron/helper/main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
@@ -22,7 +22,7 @@ function existProcess() {
process.emit('beforeExit', 0);
}
vi.doMock('../secondary-db', () => {
vi.doMock('@affine/electron/helper/db/secondary-db', () => {
return {
SecondaryWorkspaceSQLiteDB: class {
constructor(...args: any[]) {
@@ -49,7 +49,9 @@ afterEach(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 db0 = await ensureSQLiteDB(workspaceId);
expect(db0).toBeDefined();
@@ -64,7 +66,9 @@ test('can get a valid WorkspaceSQLiteDB', 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 db0 = await ensureSQLiteDB(workspaceId);
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 () => {
const { ensureSQLiteDB, db$Map } = await import('../ensure-db');
const { ensureSQLiteDB, db$Map } = await import(
'@affine/electron/helper/db/ensure-db'
);
const workspaceId = v4();
const db = await ensureSQLiteDB(workspaceId);
await db.destroy();
@@ -92,8 +98,12 @@ test('db should be removed in db$Map after destroyed', async () => {
// we have removed secondary db feature
test.skip('if db has a secondary db path, we should also poll that', async () => {
const { ensureSQLiteDB } = await import('../ensure-db');
const { storeWorkspaceMeta } = await import('../../workspace');
const { ensureSQLiteDB } = await import(
'@affine/electron/helper/db/ensure-db'
);
const { storeWorkspaceMeta } = await import(
'@affine/electron/helper/workspace'
);
const workspaceId = v4();
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary.db'),
@@ -1,18 +1,20 @@
import path from 'node:path';
import {
copyToTemp,
migrateToSubdocAndReplaceDatabase,
} from '@affine/electron/helper/db/migration';
import { SqliteConnection } from '@affine/native';
import { removeWithRetry } from '@affine-test/kit/utils/utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { copyToTemp, migrateToSubdocAndReplaceDatabase } from '../migration';
const tmpDir = path.join(__dirname, 'tmp');
const testDBFilePath = path.resolve(__dirname, 'old-db.affine');
const appDataPath = path.join(tmpDir, 'app-data');
vi.mock('../../main-rpc', () => ({
vi.mock('@affine/electron/helper/main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
@@ -1,17 +1,16 @@
import path from 'node:path';
import { dbSubjects } from '@affine/electron/helper/db/subjects';
import { removeWithRetry } from '@affine-test/kit/utils/utils';
import fs from 'fs-extra';
import { v4 } from 'uuid';
import { afterEach, expect, test, vi } from 'vitest';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { dbSubjects } from '../subjects';
const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
vi.doMock('../../main-rpc', () => ({
vi.doMock('@affine/electron/helper/main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
@@ -47,7 +46,9 @@ function getTestSubDocUpdates() {
}
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 db = await openWorkspaceDatabase(workspaceId);
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 () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
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 () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const onUpdate = 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 () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const onUpdate = 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 () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const onUpdate = 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 () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const db = await openWorkspaceDatabase(workspaceId);
const updateSub = {
@@ -8,13 +8,13 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
vi.doMock('../../db/ensure-db', () => ({
vi.doMock('@affine/electron/helper/db/ensure-db', () => ({
ensureSQLiteDB: async () => ({
destroy: () => {},
}),
}));
vi.doMock('../../main-rpc', () => ({
vi.doMock('@affine/electron/helper/main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
@@ -26,7 +26,9 @@ afterEach(async () => {
describe('list workspaces', () => {
test('listWorkspaces (valid)', async () => {
const { listWorkspaces } = await import('../handlers');
const { listWorkspaces } = await import(
'@affine/electron/helper/workspace/handlers'
);
const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
const meta = {
@@ -39,7 +41,9 @@ describe('list workspaces', () => {
});
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 workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
@@ -56,7 +60,9 @@ describe('list workspaces', () => {
describe('delete workspace', () => {
test('deleteWorkspace', async () => {
const { deleteWorkspace } = await import('../handlers');
const { deleteWorkspace } = await import(
'@affine/electron/helper/workspace/handlers'
);
const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
@@ -73,7 +79,9 @@ describe('delete workspace', () => {
describe('getWorkspaceMeta', () => {
test('can get meta', async () => {
const { getWorkspaceMeta } = await import('../meta');
const { getWorkspaceMeta } = await import(
'@affine/electron/helper/workspace/meta'
);
const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
const meta = {
@@ -85,7 +93,9 @@ describe('getWorkspaceMeta', () => {
});
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 workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
@@ -99,7 +109,9 @@ describe('getWorkspaceMeta', () => {
});
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 workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
@@ -121,7 +133,9 @@ describe('getWorkspaceMeta', () => {
});
test('storeWorkspaceMeta', async () => {
const { storeWorkspaceMeta } = await import('../handlers');
const { storeWorkspaceMeta } = await import(
'@affine/electron/helper/workspace/handlers'
);
const workspaceId = v4();
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
+3 -3
View File
@@ -28,12 +28,12 @@
{
"path": "../../common/env"
},
// Tests
{
"path": "./tsconfig.node.json"
},
{ "path": "../../../tests/kit" }
{
"path": "../../../tests/kit"
}
],
"ts-node": {
"esm": true,
@@ -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"]
}
@@ -1,12 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": true
},
"include": ["**/__tests__/**/*", "./tests"],
"references": [
{
"path": "./tsconfig.json"
}
]
}
-19
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;
}
+2 -9
View File
@@ -4,24 +4,17 @@ import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const rootDir = fileURLToPath(new URL('../../..', import.meta.url));
const pluginOutputDir = resolve(
rootDir,
'./packages/frontend/electron/dist/plugins'
);
export default defineConfig({
resolve: {
alias: {
// prevent tests using two different sources of yjs
yjs: resolve(rootDir, 'node_modules/yjs'),
'@affine/electron': resolve(rootDir, 'packages/frontend/electron/src'),
},
},
define: {
'process.env.PLUGIN_DIR': JSON.stringify(pluginOutputDir),
},
test: {
include: ['./src/**/*.spec.ts'],
exclude: ['**/node_modules', '**/dist', '**/build', '**/out'],
include: ['./test/**/*.spec.ts'],
testTimeout: 5000,
singleThread: true,
threads: false,