From 9f43c0ddc8d72798524729a29e9ff1db9f1af2e4 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Fri, 28 Jul 2023 19:43:52 -0700 Subject: [PATCH] refactor: plugin loading logic (#3448) --- .github/workflows/build.yml | 44 ++++ apps/core/package.json | 1 - apps/core/src/bootstrap/register-plugins.ts | 217 ++++++++++++-------- package.json | 4 +- packages/cli/src/bin/dev-plugin.ts | 53 ++--- plugins/copilot/package.json | 2 +- plugins/copilot/src/UI/detail-content.tsx | 5 - plugins/copilot/src/UI/header-item.tsx | 3 +- tests/affine-plugin/e2e/basic.spec.ts | 31 +++ tests/affine-plugin/package.json | 13 ++ tests/affine-plugin/playwright.config.ts | 63 ++++++ tests/affine-plugin/tsconfig.json | 16 ++ tsconfig.json | 3 + yarn.lock | 15 +- 14 files changed, 332 insertions(+), 138 deletions(-) create mode 100644 tests/affine-plugin/e2e/basic.spec.ts create mode 100644 tests/affine-plugin/package.json create mode 100644 tests/affine-plugin/playwright.config.ts create mode 100644 tests/affine-plugin/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 424abc0ba4..66663b2062 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 2726ab4089..a1e7e83bf5 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 c3cdaf3b28..67972b2984 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 87c7d35d0e..50a48d6351 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 ec1938ec51..ebb7d92736 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 19749866b0..1430f7f69b 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 60136a22d5..ed05b87506 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 b7b64b16e1..e312e6f390 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 0000000000..98354a57f0 --- /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 0000000000..d1d72567e4 --- /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 0000000000..d39bd0d2f1 --- /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 0000000000..c7587f158b --- /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 7d795d0ddb..be4309ffeb 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 28d3b06c6b..98de9351c6 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:*"