Skip to content

Commit 976098e

Browse files
committed
fix(FR-2903): use import.meta.glob to avoid esbuild glob scan on src/plugins
1 parent 2ecde6f commit 976098e

4 files changed

Lines changed: 141 additions & 22 deletions

File tree

AGENTS.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1010

1111
### Build and Production
1212

13-
- `pnpm run build` - Full production build (cleans `build/web/`, copies resources, builds React via Craco with Workbox service worker generation, builds workspace packages)
14-
- `pnpm run build:react-only` - Build only React app via Craco
13+
- `pnpm run build` - Full production build (cleans `build/web/`, copies resources, builds React via Vite with `vite-plugin-pwa` service worker generation, builds workspace packages)
14+
- `pnpm run build:react-only` - Build only React app via Vite
1515
- `pnpm run relay` - Compile GraphQL queries with Relay compiler
1616

1717
### Quality Control
@@ -23,8 +23,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2323

2424
### Testing
2525

26-
- `pnpm run test` - Run Jest tests (root: `scripts/`, `src/`)
27-
- `pnpm run test` (in `/react` directory) - Run React-specific Jest tests
26+
- `pnpm run test` - Run Vitest tests (root: `scripts/`, `src/`)
27+
- `pnpm run test` (in `/react` directory) - Run React-specific Vitest tests (`pnpm run vitest:watch` for watch mode)
2828
- E2E tests (`/e2e/`) use Playwright; require full Backend.AI cluster running first
2929

3030
### Electron App
@@ -41,30 +41,31 @@ This is a **React web application** using React 19 + Ant Design 6 + Relay 20 (Gr
4141

4242
### Key Technologies
4343

44-
- **React Build**: Webpack via @craco/craco (Create React App with customizations)
44+
- **React Build**: Vite 6 (`@vitejs/plugin-react`) with `vite-plugin-pwa`, `vite-plugin-svgr`, `vite-plugin-node-polyfills`
4545
- **Component Library Build**: Vite (`packages/backend.ai-ui/`)
46-
- **Service Worker**: workbox-webpack-plugin (GenerateSW, integrated into Craco/Webpack build)
46+
- **Service Worker**: `vite-plugin-pwa` (Workbox under the hood), integrated into the Vite build
4747
- **Package Manager**: pnpm with workspace monorepo
4848
- **Styling**: Ant Design + antd-style
4949
- **State Management**: Jotai (global UI state), Relay (server/GraphQL state)
5050
- **GraphQL**: Relay compiler with projects for both `react/` and `packages/backend.ai-ui/`
5151
- **React Compiler**: babel-plugin-react-compiler in annotation mode (`'use memo'` directive)
52-
- **Testing**: Jest for unit tests, Playwright for E2E tests
52+
- **Testing**: Vitest for unit tests (jsdom env), Playwright for E2E tests
5353
- **Linting**: ESLint 9 (flat config) + Prettier, pre-commit hooks via Husky + lint-staged
5454
- **Electron**: Desktop app wrapper with built-in websocket proxy
5555
- **Storybook**: @storybook/react-vite for `backend.ai-ui` component library
5656

5757
### Project Structure
5858

5959
```
60-
react/ # Main React application (Webpack/Craco)
60+
react/ # Main React application (Vite)
6161
src/ # Application source code
6262
components/ # React UI components
6363
pages/ # Page-level components
6464
hooks/ # Custom React hooks
6565
helper/ # Utility functions
6666
__generated__/ # Relay compiler output
67-
craco.config.cjs # Webpack customization via Craco
67+
vite.config.ts # Vite + plugins (PWA / svgr / node-polyfills) configuration
68+
vitest.config.ts # Vitest configuration (unit tests)
6869
packages/ # Monorepo workspace packages
6970
backend.ai-ui/ # Shared React component library (Vite build)
7071
backend.ai-webui-docs/# User manual documentation
@@ -87,15 +88,15 @@ Production build (`pnpm run build`) runs these steps sequentially:
8788
1. Clean and create `build/web/` output directory
8889
2. Copy `index.html`, `resources/`, `manifest/`, config files
8990
3. `pnpm run -r --stream build` builds all workspace packages:
90-
- React app (Craco/Webpack) → `react/build/` → copied to `build/web/`
91-
- Service worker (`sw.js`) generated by workbox-webpack-plugin during React build
91+
- React app (Vite) → `react/build/` → copied to `build/web/`
92+
- Service worker (`sw.js`) generated by `vite-plugin-pwa` during the React build
9293
- backend.ai-ui (Vite) → `packages/backend.ai-ui/dist/`
9394

9495
### Development Workflow
9596

9697
1. **Dev Server**: Run `pnpm run dev` (TypeScript watch + Relay watch + React dev server under [Portless](https://github.com/vercel-labs/portless)). Portless is a `devDependency`, no global install needed; `dev.mjs` auto-starts the daemon on port 1355 (HTTPS by default).
9798
2. **URL**: For branches matching `FR-XXXX` the dev URL is `https://fr-XXXX.localhost:1355`; otherwise Portless derives a branch-based subdomain (printed on startup). See `DEV_ENVIRONMENT.md` for theme color and troubleshooting.
98-
3. **Testing**: Jest unit tests + Playwright E2E tests
99+
3. **Testing**: Vitest unit tests + Playwright E2E tests
99100
4. **Linting**: ESLint 9 (flat config) + Prettier with pre-commit hooks via Husky
100101

101102
# Additional Workflow Description
@@ -134,7 +135,8 @@ Production build (`pnpm run build`) runs these steps sequentially:
134135
- **react-relay** 20, **relay-runtime** 20 - GraphQL client
135136
- **jotai** - Atomic state management
136137
- **i18next**, **react-i18next** - Internationalization
137-
- **@craco/craco** - CRA webpack customization
138+
- **vite** 6 + **@vitejs/plugin-react** - React app bundler and dev server
139+
- **vitest** 4 - Unit test runner (jsdom env)
138140
- **electron** 35 - Desktop app framework
139141

140142
### GraphQL/Relay Setup
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* global document, MutationObserver */
2+
/**
3+
* E2E fixture for the login-screen homepage-link plugin.
4+
*
5+
* Served via `page.route` in `login-plugin.spec.ts`. Pure side-effect ES
6+
* module — IIFE that finds the login form in DOM and appends a "Visit
7+
* backend.ai" link. No exports.
8+
*/
9+
(function () {
10+
'use strict';
11+
12+
const LINK_CLASS = 'bai-homepage-link';
13+
const HREF = 'https://www.backend.ai/';
14+
const LABEL = 'Visit backend.ai';
15+
16+
function inject() {
17+
const form =
18+
document.querySelector('.ant-modal-content form') ||
19+
document.querySelector('.ant-modal-body form') ||
20+
document.querySelector('form.ant-form') ||
21+
document.querySelector('form');
22+
if (!form) return false;
23+
if (form.querySelector('.' + LINK_CLASS)) return true;
24+
25+
const wrapper = document.createElement('div');
26+
wrapper.className = LINK_CLASS;
27+
wrapper.style.textAlign = 'center';
28+
wrapper.style.marginTop = '8px';
29+
wrapper.style.fontSize = '13px';
30+
31+
const link = document.createElement('a');
32+
link.href = HREF;
33+
link.target = '_blank';
34+
link.rel = 'noopener noreferrer';
35+
link.textContent = LABEL;
36+
37+
wrapper.appendChild(link);
38+
form.appendChild(wrapper);
39+
return true;
40+
}
41+
42+
if (inject()) return;
43+
44+
const observer = new MutationObserver(() => {
45+
if (inject()) observer.disconnect();
46+
});
47+
observer.observe(document.body, { childList: true, subtree: true });
48+
})();

e2e/plugin/login-plugin.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// E2E tests for the login-screen plugin slot (`config.plugin.login`).
2+
//
3+
// Login plugins follow the same runtime-fetch model as page plugins:
4+
// `LoginView.tsx` imports `/dist/plugins/<name>.js` (or
5+
// `${apiEndpoint}/dist/plugins/<name>.js` in Electron) at runtime, so
6+
// `page.route` can fulfill the request with fixture content — the same
7+
// pattern as the existing page-plugin tests in this directory.
8+
import { webuiEndpoint, modifyConfigToml } from '../utils/test-util';
9+
import { test, expect } from '@playwright/test';
10+
import fs from 'fs';
11+
import path from 'path';
12+
13+
const PLUGIN_NAME = 'backend-ai-homepage-link';
14+
const HOMEPAGE_LINK_PLUGIN_JS = fs.readFileSync(
15+
path.join(__dirname, `${PLUGIN_NAME}.js`),
16+
'utf-8',
17+
);
18+
19+
test.describe(
20+
'Login Plugin',
21+
{ tag: ['@plugin', '@functional', '@regression'] },
22+
() => {
23+
test('homepage-link plugin injects an external link into the login form', async ({
24+
page,
25+
request,
26+
}) => {
27+
await modifyConfigToml(page, request, {
28+
plugin: { login: PLUGIN_NAME },
29+
});
30+
31+
await page.route(`**/dist/plugins/${PLUGIN_NAME}.js`, async (route) => {
32+
await route.fulfill({
33+
status: 200,
34+
contentType: 'application/javascript',
35+
body: HOMEPAGE_LINK_PLUGIN_JS,
36+
});
37+
});
38+
39+
await page.goto(webuiEndpoint);
40+
41+
const link = page.locator('.bai-homepage-link a');
42+
await expect(link).toBeVisible();
43+
await expect(link).toHaveAttribute('href', 'https://www.backend.ai/');
44+
await expect(link).toHaveAttribute('target', '_blank');
45+
await expect(link).toHaveText(/Visit backend\.ai/i);
46+
});
47+
48+
test('no link appears when the plugin slot is unset', async ({
49+
page,
50+
request,
51+
}) => {
52+
await modifyConfigToml(page, request, {
53+
plugin: { login: '' },
54+
});
55+
56+
await page.goto(webuiEndpoint);
57+
58+
await page.locator('form').first().waitFor({ state: 'visible' });
59+
await expect(page.locator('.bai-homepage-link')).toHaveCount(0);
60+
});
61+
},
62+
);

react/src/components/LoginView.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,12 @@ const LoginView: React.FC<{
180180
}
181181
}, [isConfigLoaded, atomLoginConfig]);
182182

183-
// Load login plugin when config is ready
183+
// Load login plugin when config is ready.
184+
// Mirrors `PluginLoader.tsx`'s URL resolution so login plugins follow
185+
// the same deployment model as page plugins: the file is served by the
186+
// backend WebServer (or a Vite/static host that mounts `/dist/plugins/`).
187+
// No build-time bundling — plugins can be deployed independently of the
188+
// WebUI bundle.
184189
useEffect(() => {
185190
if (!isConfigLoaded || !loginPlugin) return;
186191

@@ -189,17 +194,19 @@ const LoginView: React.FC<{
189194
const sanitizedPlugin = loginPlugin.replace(/[^a-zA-Z0-9_-]/g, '');
190195
if (!sanitizedPlugin || sanitizedPlugin !== loginPlugin) return;
191196

192-
// Build the plugin path at runtime, in a variable, so neither Vite nor
193-
// esbuild's optimizeDeps scanner can statically analyze the specifier.
194-
// A template-literal with a static prefix (e.g. `../../../src/plugins/…`)
195-
// is treated by esbuild as a glob and fails to resolve in dev because
196-
// `webui-ai/src/plugins/` only exists in production builds. `@vite-ignore`
197-
// alone is not enough — esbuild's scanner does not honor that comment.
198-
const pluginUrl = `../../../src/plugins/${sanitizedPlugin}`;
197+
const isElectronEnv = (globalThis as Record<string, unknown>).isElectron;
198+
const pluginUrl =
199+
isElectronEnv && apiEndpoint
200+
? `${apiEndpoint}/dist/plugins/${sanitizedPlugin}.js`
201+
: `/dist/plugins/${sanitizedPlugin}.js`;
202+
203+
// `@vite-ignore` opts out of Vite's static analysis. The URL is an
204+
// absolute path (no relative `..` traversal), so esbuild's
205+
// optimizeDeps scanner does not treat it as a module specifier.
199206
import(/* @vite-ignore */ pluginUrl).catch(() => {
200207
setLoginError({ message: t('error.LoginFailed') });
201208
});
202-
}, [isConfigLoaded, loginPlugin, t]);
209+
}, [isConfigLoaded, loginPlugin, apiEndpoint, t]);
203210

204211
// Keep configRef in sync
205212
useEffect(() => {

0 commit comments

Comments
 (0)