diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 424abc0ba..66663b206 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -110,6 +110,8 @@ jobs:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
+ - name: Build Plugins
+ run: yarn run build:plugins
- name: Build Core
run: yarn nx build @affine/core
- name: Upload core artifact
@@ -207,6 +209,48 @@ jobs:
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"
+ 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:
name: E2E Test
runs-on: ubuntu-latest
diff --git a/apps/core/package.json b/apps/core/package.json
index 2726ab408..a1e7e83bf 100644
--- a/apps/core/package.json
+++ b/apps/core/package.json
@@ -11,7 +11,6 @@
"dependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/component": "workspace:*",
- "@affine/copilot": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
diff --git a/apps/core/src/bootstrap/register-plugins.ts b/apps/core/src/bootstrap/register-plugins.ts
index c3cdaf3b2..67972b298 100644
--- a/apps/core/src/bootstrap/register-plugins.ts
+++ b/apps/core/src/bootstrap/register-plugins.ts
@@ -2,6 +2,7 @@
import 'ses';
import * as AFFiNEComponent from '@affine/component';
+import { DebugLogger } from '@affine/debug';
import { FormatQuickBar } from '@blocksuite/blocks';
import * as BlockSuiteBlocksStd from '@blocksuite/blocks/std';
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) =>
React.createElement(
Provider,
@@ -142,95 +151,127 @@ const createGlobalThis = () => {
};
const group = new DisposableGroup();
-const pluginList = (await (
- await fetch(new URL(`./plugins/plugin-list.json`, window.location.origin))
-).json()) as { name: string; assets: string[]; release: boolean }[];
+
+declare global {
+ // 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(
- pluginList.map(({ name: plugin, release, assets }) => {
- if (!release && process.env.NODE_ENV !== 'development') {
- return Promise.resolve();
- }
- const pluginCompartment = new Compartment(createGlobalThis(), {});
- const pluginGlobalThis = pluginCompartment.globalThis;
- const baseURL = new URL(`./plugins/${plugin}/`, window.location.origin);
- const entryURL = new URL('index.js', baseURL);
- rootStore.set(registeredPluginAtom, prev => [...prev, plugin]);
- return fetch(entryURL).then(async res => {
- if (assets.length > 0) {
- await Promise.all(
- assets.map(async asset => {
- if (asset.endsWith('.css')) {
- const res = await fetch(new URL(asset, baseURL));
- if (res.ok) {
- // todo: how to put css file into sandbox?
- return res.text().then(text => {
- const style = document.createElement('style');
- style.setAttribute('plugin-id', plugin);
- style.textContent = text;
- document.head.appendChild(style);
- });
- }
- return null;
- } else {
- return Promise.resolve();
- }
- })
- );
- }
- const codeText = await res.text();
- pluginCompartment.evaluate(codeText, {
- __evadeHtmlCommentTest__: true,
- });
- pluginGlobalThis.__INTERNAL__ENTRY = {
- register: (part, callback) => {
- if (part === 'headerItem') {
- rootStore.set(headerItemsAtom, items => ({
- ...items,
- [plugin]: callback as CallbackMap['headerItem'],
- }));
- } 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}`);
+ [...builtinPluginUrl].map(url => {
+ return fetch(`${url}/package.json`)
+ .then(async res => {
+ const packageJson = await res.json();
+ const {
+ name: pluginName,
+ affinePlugin: {
+ release,
+ entry: { core },
+ assets,
+ },
+ } = packageJson;
+ globalThis.__pluginPackageJson__.push(packageJson);
+ logger.debug(`registering plugin ${pluginName}`);
+ logger.debug(`package.json: ${packageJson}`);
+ if (!release) {
+ return Promise.resolve();
+ }
+ const pluginCompartment = new Compartment(createGlobalThis(), {});
+ const pluginGlobalThis = pluginCompartment.globalThis;
+ const baseURL = url;
+ const entryURL = `${baseURL}/${core}`;
+ rootStore.set(registeredPluginAtom, prev => [...prev, pluginName]);
+ await fetch(entryURL).then(async res => {
+ if (assets.length > 0) {
+ await Promise.all(
+ 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?
+ return res.text().then(text => {
+ const style = document.createElement('style');
+ style.setAttribute('plugin-id', pluginName);
+ style.textContent = text;
+ document.head.appendChild(style);
+ });
+ }
+ return null;
+ } else {
+ return Promise.resolve();
+ }
+ })
+ );
}
- },
- 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);
- });
+ const codeText = await res.text();
+ pluginCompartment.evaluate(codeText, {
+ __evadeHtmlCommentTest__: true,
+ });
+ pluginGlobalThis.__INTERNAL__ENTRY = {
+ register: (part, callback) => {
+ logger.info(`Registering ${pluginName} to ${part}`);
+ if (part === 'headerItem') {
+ rootStore.set(headerItemsAtom, items => ({
+ ...items,
+ [pluginName]: callback as CallbackMap['headerItem'],
+ }));
+ } 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);
+ });
})
-);
-
-console.log('register plugins finished');
+).then(() => {
+ console.info('All plugins loaded');
+});
diff --git a/package.json b/package.json
index 87c7d35d0..50a48d635 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"tests/fixtures",
"tests/kit",
"tests/affine-legacy/*",
- "tests/affine-local"
+ "tests/affine-local",
+ "tests/affine-plugin"
],
"engines": {
"node": ">=18.16.1 <19.0.0"
@@ -20,7 +21,6 @@
"scripts": {
"dev": "dev-core",
"dev:electron": "yarn workspace @affine/electron dev:app",
- "dev:plugins": "./apps/electron/scripts/plugins/dev-plugins.mjs",
"build": "yarn nx build @affine/core",
"build:electron": "yarn nx build @affine/electron",
"build:storage": "yarn nx run-many -t build -p @affine/storage",
diff --git a/packages/cli/src/bin/dev-plugin.ts b/packages/cli/src/bin/dev-plugin.ts
index ec1938ec5..ebb7d9273 100644
--- a/packages/cli/src/bin/dev-plugin.ts
+++ b/packages/cli/src/bin/dev-plugin.ts
@@ -1,6 +1,5 @@
import { ok } from 'node:assert';
-import { existsSync } from 'node:fs';
-import { mkdir, open, readFile } from 'node:fs/promises';
+import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { parseArgs } from 'node:util';
@@ -109,8 +108,6 @@ const serverOutDir = path.resolve(
plugin
);
-const pluginListJsonPath = path.resolve(outDir, 'plugin-list.json');
-
const coreEntry = path.resolve(pluginDir, json.affinePlugin.entry.core);
if (json.affinePlugin.entry.server) {
const serverEntry = path.resolve(pluginDir, json.affinePlugin.entry.server);
@@ -162,41 +159,23 @@ await build({
vanillaExtractPlugin(),
react(),
{
- name: 'generate-list-json',
+ name: 'generate-package.json',
async generateBundle() {
- if (!existsSync(outDir)) {
- await mkdir(outDir, { recursive: true });
- }
- const file = await open(pluginListJsonPath, 'as+', 0o777);
- const txt = await file.readFile({
- encoding: 'utf-8',
+ const packageJson = {
+ name: json.name,
+ affinePlugin: {
+ release: json.affinePlugin.release,
+ entry: {
+ 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);
- }
},
},
],
diff --git a/plugins/copilot/package.json b/plugins/copilot/package.json
index 19749866b..1430f7f69 100644
--- a/plugins/copilot/package.json
+++ b/plugins/copilot/package.json
@@ -1,5 +1,5 @@
{
- "name": "@affine/copilot",
+ "name": "@affine/copilot-plugin",
"private": true,
"affinePlugin": {
"release": false,
diff --git a/plugins/copilot/src/UI/detail-content.tsx b/plugins/copilot/src/UI/detail-content.tsx
index 60136a22d..ed05b8750 100644
--- a/plugins/copilot/src/UI/detail-content.tsx
+++ b/plugins/copilot/src/UI/detail-content.tsx
@@ -1,6 +1,5 @@
import { IconButton } from '@affine/component';
import { SendIcon } from '@blocksuite/icons';
-import { contentLayoutAtom } from '@toeverything/plugin-infra/atom';
import { useAtomValue, useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import { Suspense, useCallback, useState } from 'react';
@@ -62,11 +61,7 @@ const DetailContentImpl = () => {
};
export const DetailContent = (): ReactElement => {
- const layout = useAtomValue(contentLayoutAtom);
const key = useAtomValue(openAIApiKeyAtom);
- if (layout === 'editor' || layout.second !== 'copilot') {
- return <>>;
- }
if (!key) {
return Please set OpenAI API Key in the debug panel.;
}
diff --git a/plugins/copilot/src/UI/header-item.tsx b/plugins/copilot/src/UI/header-item.tsx
index b7b64b16e..e312e6f39 100644
--- a/plugins/copilot/src/UI/header-item.tsx
+++ b/plugins/copilot/src/UI/header-item.tsx
@@ -11,12 +11,13 @@ export const HeaderItem = (): ReactElement => {
+ // todo: abstract a context function to open a new tab
setLayout(layout => {
if (layout === 'editor') {
return {
direction: 'horizontal',
first: 'editor',
- second: 'copilot',
+ second: '@affine/copilot',
splitPercentage: 70,
};
} else {
diff --git a/tests/affine-plugin/e2e/basic.spec.ts b/tests/affine-plugin/e2e/basic.spec.ts
new file mode 100644
index 000000000..98354a57f
--- /dev/null
+++ b/tests/affine-plugin/e2e/basic.spec.ts
@@ -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(),
+ },
+ ]);
+});
diff --git a/tests/affine-plugin/package.json b/tests/affine-plugin/package.json
new file mode 100644
index 000000000..d1d72567e
--- /dev/null
+++ b/tests/affine-plugin/package.json
@@ -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"
+}
diff --git a/tests/affine-plugin/playwright.config.ts b/tests/affine-plugin/playwright.config.ts
new file mode 100644
index 000000000..d39bd0d2f
--- /dev/null
+++ b/tests/affine-plugin/playwright.config.ts
@@ -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;
diff --git a/tests/affine-plugin/tsconfig.json b/tests/affine-plugin/tsconfig.json
new file mode 100644
index 000000000..c7587f158
--- /dev/null
+++ b/tests/affine-plugin/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "outDir": "lib"
+ },
+ "include": ["e2e"],
+ "references": [
+ {
+ "path": "../kit"
+ },
+ {
+ "path": "../fixtures"
+ }
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 7d795d0dd..be4309ffe 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -155,6 +155,9 @@
{
"path": "./tests/affine-local"
},
+ {
+ "path": "./tests/affine-plugin"
+ },
{
"path": "./tests/affine-legacy/0.7.0-canary.18"
}
diff --git a/yarn.lock b/yarn.lock
index 28d3b06c6..98de9351c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -48,6 +48,16 @@ __metadata:
languageName: unknown
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":
version: 0.0.0-use.local
resolution: "@affine-test/fixtures@workspace:tests/fixtures"
@@ -155,9 +165,9 @@ __metadata:
languageName: unknown
linkType: soft
-"@affine/copilot@workspace:*, @affine/copilot@workspace:plugins/copilot":
+"@affine/copilot-plugin@workspace:plugins/copilot":
version: 0.0.0-use.local
- resolution: "@affine/copilot@workspace:plugins/copilot"
+ resolution: "@affine/copilot-plugin@workspace:plugins/copilot"
dependencies:
"@affine/component": "workspace:*"
"@toeverything/plugin-infra": "workspace:*"
@@ -183,7 +193,6 @@ __metadata:
dependencies:
"@affine-test/fixtures": "workspace:*"
"@affine/component": "workspace:*"
- "@affine/copilot": "workspace:*"
"@affine/debug": "workspace:*"
"@affine/env": "workspace:*"
"@affine/graphql": "workspace:*"