refactor: plugin loading logic (#3448)

This commit is contained in:
Alex Yang
2023-07-28 19:43:52 -07:00
committed by GitHub
parent 4cb1bf6a9f
commit 9f43c0ddc8
14 changed files with 332 additions and 138 deletions

View File

@@ -110,6 +110,8 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node
- name: Build Plugins
run: yarn run build:plugins
- name: Build Core - name: Build Core
run: yarn nx build @affine/core run: yarn nx build @affine/core
- name: Upload core artifact - name: Upload core artifact
@@ -207,6 +209,48 @@ jobs:
run: | run: |
yarn exec concurrently -k -s first -n "SB,TEST" -c "magenta,blue" "yarn exec serve ./storybook-static -l 6006" "yarn exec wait-on tcp:6006 && yarn test" yarn exec concurrently -k -s first -n "SB,TEST" -c "magenta,blue" "yarn exec serve ./storybook-static -l 6006" "yarn exec wait-on tcp:6006 && yarn test"
e2e-plugin-test:
name: E2E Plugin Test
runs-on: ubuntu-latest
environment: development
needs: build-core
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
electron-install: false
- name: Download core artifact
uses: actions/download-artifact@v3
with:
name: core
path: ./apps/core/dist
- name: Run playwright tests
run: yarn e2e --forbid-only
working-directory: tests/affine-plugin
env:
COVERAGE: true
- name: Collect code coverage report
run: yarn exec nyc report -t .nyc_output --report-dir .coverage --reporter=lcov
- name: Upload e2e test coverage results
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./.coverage/lcov.info
flags: e2e-plugin-test
name: affine
fail_ci_if_error: false
- name: Upload test results
if: ${{ failure() }}
uses: actions/upload-artifact@v3
with:
name: test-results-e2e-plugin
path: ./test-results
if-no-files-found: ignore
e2e-test: e2e-test:
name: E2E Test name: E2E Test
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"@affine-test/fixtures": "workspace:*", "@affine-test/fixtures": "workspace:*",
"@affine/component": "workspace:*", "@affine/component": "workspace:*",
"@affine/copilot": "workspace:*",
"@affine/debug": "workspace:*", "@affine/debug": "workspace:*",
"@affine/env": "workspace:*", "@affine/env": "workspace:*",
"@affine/graphql": "workspace:*", "@affine/graphql": "workspace:*",

View File

@@ -2,6 +2,7 @@
import 'ses'; import 'ses';
import * as AFFiNEComponent from '@affine/component'; import * as AFFiNEComponent from '@affine/component';
import { DebugLogger } from '@affine/debug';
import { FormatQuickBar } from '@blocksuite/blocks'; import { FormatQuickBar } from '@blocksuite/blocks';
import * as BlockSuiteBlocksStd from '@blocksuite/blocks/std'; import * as BlockSuiteBlocksStd from '@blocksuite/blocks/std';
import { DisposableGroup } from '@blocksuite/global/utils'; import { DisposableGroup } from '@blocksuite/global/utils';
@@ -40,6 +41,14 @@ if (!process.env.COVERAGE) {
}); });
} }
const builtinPluginUrl = new Set([
'/plugins/bookmark',
'/plugins/copilot',
'/plugins/hello-world',
]);
const logger = new DebugLogger('register-plugins');
const PluginProvider = ({ children }: PropsWithChildren) => const PluginProvider = ({ children }: PropsWithChildren) =>
React.createElement( React.createElement(
Provider, Provider,
@@ -142,95 +151,127 @@ const createGlobalThis = () => {
}; };
const group = new DisposableGroup(); const group = new DisposableGroup();
const pluginList = (await (
await fetch(new URL(`./plugins/plugin-list.json`, window.location.origin)) declare global {
).json()) as { name: string; assets: string[]; release: boolean }[]; // eslint-disable-next-line no-var
var __pluginPackageJson__: unknown[];
}
globalThis.__pluginPackageJson__ = [];
interface PluginLoadedEvent extends CustomEvent<{ plugins: unknown[] }> {}
// add to window
declare global {
interface WindowEventMap {
'plugin-loaded': PluginLoadedEvent;
}
}
await Promise.all( await Promise.all(
pluginList.map(({ name: plugin, release, assets }) => { [...builtinPluginUrl].map(url => {
if (!release && process.env.NODE_ENV !== 'development') { return fetch(`${url}/package.json`)
return Promise.resolve(); .then(async res => {
} const packageJson = await res.json();
const pluginCompartment = new Compartment(createGlobalThis(), {}); const {
const pluginGlobalThis = pluginCompartment.globalThis; name: pluginName,
const baseURL = new URL(`./plugins/${plugin}/`, window.location.origin); affinePlugin: {
const entryURL = new URL('index.js', baseURL); release,
rootStore.set(registeredPluginAtom, prev => [...prev, plugin]); entry: { core },
return fetch(entryURL).then(async res => { assets,
if (assets.length > 0) { },
await Promise.all( } = packageJson;
assets.map(async asset => { globalThis.__pluginPackageJson__.push(packageJson);
if (asset.endsWith('.css')) { logger.debug(`registering plugin ${pluginName}`);
const res = await fetch(new URL(asset, baseURL)); logger.debug(`package.json: ${packageJson}`);
if (res.ok) { if (!release) {
// todo: how to put css file into sandbox? return Promise.resolve();
return res.text().then(text => { }
const style = document.createElement('style'); const pluginCompartment = new Compartment(createGlobalThis(), {});
style.setAttribute('plugin-id', plugin); const pluginGlobalThis = pluginCompartment.globalThis;
style.textContent = text; const baseURL = url;
document.head.appendChild(style); const entryURL = `${baseURL}/${core}`;
}); rootStore.set(registeredPluginAtom, prev => [...prev, pluginName]);
} await fetch(entryURL).then(async res => {
return null; if (assets.length > 0) {
} else { await Promise.all(
return Promise.resolve(); assets.map(async (asset: string) => {
} if (asset.endsWith('.css')) {
}) const res = await fetch(`${baseURL}/${asset}`);
); if (res.ok) {
} // todo: how to put css file into sandbox?
const codeText = await res.text(); return res.text().then(text => {
pluginCompartment.evaluate(codeText, { const style = document.createElement('style');
__evadeHtmlCommentTest__: true, style.setAttribute('plugin-id', pluginName);
}); style.textContent = text;
pluginGlobalThis.__INTERNAL__ENTRY = { document.head.appendChild(style);
register: (part, callback) => { });
if (part === 'headerItem') { }
rootStore.set(headerItemsAtom, items => ({ return null;
...items, } else {
[plugin]: callback as CallbackMap['headerItem'], return Promise.resolve();
})); }
} else if (part === 'editor') { })
rootStore.set(editorItemsAtom, items => ({ );
...items,
[plugin]: callback as CallbackMap['editor'],
}));
} else if (part === 'window') {
rootStore.set(windowItemsAtom, items => ({
...items,
[plugin]: callback as CallbackMap['window'],
}));
} else if (part === 'setting') {
console.log('setting');
rootStore.set(settingItemsAtom, items => ({
...items,
[plugin]: callback as CallbackMap['setting'],
}));
} else if (part === 'formatBar') {
console.log('1');
FormatQuickBar.customElements.push((page, getBlockRange) => {
console.log('2');
const div = document.createElement('div');
(callback as CallbackMap['formatBar'])(div, page, getBlockRange);
return div;
});
} else {
throw new Error(`Unknown part: ${part}`);
} }
}, const codeText = await res.text();
utils: { pluginCompartment.evaluate(codeText, {
PluginProvider, __evadeHtmlCommentTest__: true,
}, });
} satisfies PluginContext; pluginGlobalThis.__INTERNAL__ENTRY = {
const dispose = pluginCompartment.evaluate( register: (part, callback) => {
'exports.entry(__INTERNAL__ENTRY)' logger.info(`Registering ${pluginName} to ${part}`);
); if (part === 'headerItem') {
if (typeof dispose !== 'function') { rootStore.set(headerItemsAtom, items => ({
throw new Error('Plugin entry must return a function'); ...items,
} [pluginName]: callback as CallbackMap['headerItem'],
pluginGlobalThis.__INTERNAL__ENTRY = undefined; }));
group.add(dispose); } else if (part === 'editor') {
}); rootStore.set(editorItemsAtom, items => ({
...items,
[pluginName]: callback as CallbackMap['editor'],
}));
} else if (part === 'window') {
rootStore.set(windowItemsAtom, items => ({
...items,
[pluginName]: callback as CallbackMap['window'],
}));
} else if (part === 'setting') {
rootStore.set(settingItemsAtom, items => ({
...items,
[pluginName]: callback as CallbackMap['setting'],
}));
} else if (part === 'formatBar') {
FormatQuickBar.customElements.push((page, getBlockRange) => {
const div = document.createElement('div');
(callback as CallbackMap['formatBar'])(
div,
page,
getBlockRange
);
return div;
});
} else {
throw new Error(`Unknown part: ${part}`);
}
},
utils: {
PluginProvider,
},
} satisfies PluginContext;
const dispose = pluginCompartment.evaluate(
'exports.entry(__INTERNAL__ENTRY)'
);
if (typeof dispose !== 'function') {
throw new Error('Plugin entry must return a function');
}
pluginGlobalThis.__INTERNAL__ENTRY = undefined;
group.add(dispose);
});
})
.catch(e => {
console.error(`error when fetch plugin from ${url}`, e);
});
}) })
); ).then(() => {
console.info('All plugins loaded');
console.log('register plugins finished'); });

View File

@@ -12,7 +12,8 @@
"tests/fixtures", "tests/fixtures",
"tests/kit", "tests/kit",
"tests/affine-legacy/*", "tests/affine-legacy/*",
"tests/affine-local" "tests/affine-local",
"tests/affine-plugin"
], ],
"engines": { "engines": {
"node": ">=18.16.1 <19.0.0" "node": ">=18.16.1 <19.0.0"
@@ -20,7 +21,6 @@
"scripts": { "scripts": {
"dev": "dev-core", "dev": "dev-core",
"dev:electron": "yarn workspace @affine/electron dev:app", "dev:electron": "yarn workspace @affine/electron dev:app",
"dev:plugins": "./apps/electron/scripts/plugins/dev-plugins.mjs",
"build": "yarn nx build @affine/core", "build": "yarn nx build @affine/core",
"build:electron": "yarn nx build @affine/electron", "build:electron": "yarn nx build @affine/electron",
"build:storage": "yarn nx run-many -t build -p @affine/storage", "build:storage": "yarn nx run-many -t build -p @affine/storage",

View File

@@ -1,6 +1,5 @@
import { ok } from 'node:assert'; import { ok } from 'node:assert';
import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises';
import { mkdir, open, readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { parseArgs } from 'node:util'; import { parseArgs } from 'node:util';
@@ -109,8 +108,6 @@ const serverOutDir = path.resolve(
plugin plugin
); );
const pluginListJsonPath = path.resolve(outDir, 'plugin-list.json');
const coreEntry = path.resolve(pluginDir, json.affinePlugin.entry.core); const coreEntry = path.resolve(pluginDir, json.affinePlugin.entry.core);
if (json.affinePlugin.entry.server) { if (json.affinePlugin.entry.server) {
const serverEntry = path.resolve(pluginDir, json.affinePlugin.entry.server); const serverEntry = path.resolve(pluginDir, json.affinePlugin.entry.server);
@@ -162,41 +159,23 @@ await build({
vanillaExtractPlugin(), vanillaExtractPlugin(),
react(), react(),
{ {
name: 'generate-list-json', name: 'generate-package.json',
async generateBundle() { async generateBundle() {
if (!existsSync(outDir)) { const packageJson = {
await mkdir(outDir, { recursive: true }); name: json.name,
} affinePlugin: {
const file = await open(pluginListJsonPath, 'as+', 0o777); release: json.affinePlugin.release,
const txt = await file.readFile({ entry: {
encoding: 'utf-8', core: 'index.js',
},
assets: [...metadata.assets],
},
};
this.emitFile({
type: 'asset',
fileName: 'package.json',
source: JSON.stringify(packageJson, null, 2),
}); });
if (!txt) {
console.log('generate plugin-list.json');
await file.write(
JSON.stringify([
{
release: json.affinePlugin.release,
name: plugin,
assets: [...metadata.assets],
},
])
);
} else {
console.log('modify plugin-list.json');
const list = JSON.parse(txt);
const index = list.findIndex((item: any) => item.name === plugin);
if (index === -1) {
list.push({
release: json.affinePlugin.release,
name: plugin,
assets: [...metadata.assets],
});
} else {
list[index].assets = [...metadata.assets];
}
await file.write(JSON.stringify(list), 0);
}
}, },
}, },
], ],

View File

@@ -1,5 +1,5 @@
{ {
"name": "@affine/copilot", "name": "@affine/copilot-plugin",
"private": true, "private": true,
"affinePlugin": { "affinePlugin": {
"release": false, "release": false,

View File

@@ -1,6 +1,5 @@
import { IconButton } from '@affine/component'; import { IconButton } from '@affine/component';
import { SendIcon } from '@blocksuite/icons'; import { SendIcon } from '@blocksuite/icons';
import { contentLayoutAtom } from '@toeverything/plugin-infra/atom';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { Suspense, useCallback, useState } from 'react'; import { Suspense, useCallback, useState } from 'react';
@@ -62,11 +61,7 @@ const DetailContentImpl = () => {
}; };
export const DetailContent = (): ReactElement => { export const DetailContent = (): ReactElement => {
const layout = useAtomValue(contentLayoutAtom);
const key = useAtomValue(openAIApiKeyAtom); const key = useAtomValue(openAIApiKeyAtom);
if (layout === 'editor' || layout.second !== 'copilot') {
return <></>;
}
if (!key) { if (!key) {
return <span>Please set OpenAI API Key in the debug panel.</span>; return <span>Please set OpenAI API Key in the debug panel.</span>;
} }

View File

@@ -11,12 +11,13 @@ export const HeaderItem = (): ReactElement => {
<IconButton <IconButton
onClick={useCallback( onClick={useCallback(
() => () =>
// todo: abstract a context function to open a new tab
setLayout(layout => { setLayout(layout => {
if (layout === 'editor') { if (layout === 'editor') {
return { return {
direction: 'horizontal', direction: 'horizontal',
first: 'editor', first: 'editor',
second: 'copilot', second: '@affine/copilot',
splitPercentage: 70, splitPercentage: 70,
}; };
} else { } else {

View File

@@ -0,0 +1,31 @@
import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import { waitEditorLoad } from '@affine-test/kit/utils/page-logic';
import { expect } from '@playwright/test';
test('plugin should exist', async ({ page }) => {
await openHomePage(page);
await waitEditorLoad(page);
await page.route('**/plugins/**/package.json', route => route.fetch(), {
times: 3,
});
await page.waitForTimeout(50);
const packageJson = await page.evaluate(() => {
// @ts-expect-error
return window.__pluginPackageJson__;
});
expect(packageJson).toEqual([
{
name: '@affine/bookmark-plugin',
affinePlugin: expect.anything(),
},
{
name: '@affine/copilot-plugin',
affinePlugin: expect.anything(),
},
{
name: '@affine/hello-world-plugin',
affinePlugin: expect.anything(),
},
]);
});

View File

@@ -0,0 +1,13 @@
{
"name": "@affine-test/affine-plugin",
"private": true,
"scripts": {
"e2e": "yarn playwright test"
},
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine-test/kit": "workspace:*",
"@playwright/test": "^1.36.2"
},
"version": "0.7.0-canary.59"
}

View File

@@ -0,0 +1,63 @@
import type {
PlaywrightTestConfig,
PlaywrightWorkerOptions,
} from '@playwright/test';
// import { devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './e2e',
fullyParallel: true,
timeout: process.env.CI ? 50_000 : 30_000,
use: {
baseURL: 'http://localhost:8080/',
browserName:
(process.env.BROWSER as PlaywrightWorkerOptions['browserName']) ??
'chromium',
permissions: ['clipboard-read', 'clipboard-write'],
viewport: { width: 1440, height: 800 },
actionTimeout: 5 * 1000,
locale: 'en-US',
// Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer
// You can open traces locally(`npx playwright show-trace trace.zip`)
// or in your browser on [Playwright Trace Viewer](https://trace.playwright.dev/).
trace: 'on-first-retry',
// Record video only when retrying a test for the first time.
video: 'on-first-retry',
},
forbidOnly: !!process.env.CI,
workers: 4,
retries: 1,
// 'github' for GitHub Actions CI to generate annotations, plus a concise 'dot'
// default 'list' when running locally
// See https://playwright.dev/docs/test-reporters#github-actions-annotations
reporter: process.env.CI ? 'github' : 'list',
webServer: [
// Intentionally not building the web, reminds you to run it by yourself.
{
command: 'yarn run start:web-static',
port: 8080,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
env: {
COVERAGE: process.env.COVERAGE || 'false',
},
},
],
};
if (process.env.CI) {
config.retries = 3;
config.workers = '50%';
}
export default config;

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"esModuleInterop": true,
"outDir": "lib"
},
"include": ["e2e"],
"references": [
{
"path": "../kit"
},
{
"path": "../fixtures"
}
]
}

View File

@@ -155,6 +155,9 @@
{ {
"path": "./tests/affine-local" "path": "./tests/affine-local"
}, },
{
"path": "./tests/affine-plugin"
},
{ {
"path": "./tests/affine-legacy/0.7.0-canary.18" "path": "./tests/affine-legacy/0.7.0-canary.18"
} }

View File

@@ -48,6 +48,16 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@affine-test/affine-plugin@workspace:tests/affine-plugin":
version: 0.0.0-use.local
resolution: "@affine-test/affine-plugin@workspace:tests/affine-plugin"
dependencies:
"@affine-test/fixtures": "workspace:*"
"@affine-test/kit": "workspace:*"
"@playwright/test": ^1.36.2
languageName: unknown
linkType: soft
"@affine-test/fixtures@workspace:*, @affine-test/fixtures@workspace:tests/fixtures": "@affine-test/fixtures@workspace:*, @affine-test/fixtures@workspace:tests/fixtures":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine-test/fixtures@workspace:tests/fixtures" resolution: "@affine-test/fixtures@workspace:tests/fixtures"
@@ -155,9 +165,9 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@affine/copilot@workspace:*, @affine/copilot@workspace:plugins/copilot": "@affine/copilot-plugin@workspace:plugins/copilot":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine/copilot@workspace:plugins/copilot" resolution: "@affine/copilot-plugin@workspace:plugins/copilot"
dependencies: dependencies:
"@affine/component": "workspace:*" "@affine/component": "workspace:*"
"@toeverything/plugin-infra": "workspace:*" "@toeverything/plugin-infra": "workspace:*"
@@ -183,7 +193,6 @@ __metadata:
dependencies: dependencies:
"@affine-test/fixtures": "workspace:*" "@affine-test/fixtures": "workspace:*"
"@affine/component": "workspace:*" "@affine/component": "workspace:*"
"@affine/copilot": "workspace:*"
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@affine/graphql": "workspace:*" "@affine/graphql": "workspace:*"