diff --git a/package.json b/package.json index b9d534a6..c76e1b96 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "website:preview": "pnpm --dir website run preview", "website:install": "pnpm --dir website install", "e2e:install": "pnpm --dir tests/e2e install", + "e2e:build": "pnpm run desktop:build:fast", "e2e:test": "pnpm --dir tests/e2e test", "e2e:test:l0": "pnpm --dir tests/e2e run test:l0", "e2e:test:l0:all": "pnpm --dir tests/e2e run test:l0:all", diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index 1fcefaab..b688d531 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -29,12 +29,14 @@ import { import type { SceneTabId } from '@/app/components/SceneBar/types'; import { getMiniAppIconGradient, renderMiniAppIcon } from '../utils/miniAppIcons'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useMiniAppStore } from '../miniAppStore'; import './MiniAppGalleryView.scss'; const log = createLogger('MiniAppGalleryView'); const MiniAppGalleryView: React.FC = () => { + const { t } = useI18n('common'); const apps = useMiniAppStore((state) => state.apps); const loading = useMiniAppStore((state) => state.loading); const runningWorkerIds = useMiniAppStore((state) => state.runningWorkerIds); @@ -147,7 +149,7 @@ const MiniAppGalleryView: React.FC = () => { const selected = await open({ directory: true, multiple: false, - title: '选择小应用目录(需包含 meta.json 与 source/)', + title: t('miniApps.importFolderTitle'), }); const path = Array.isArray(selected) ? selected[0] : selected; if (!path) return; @@ -177,8 +179,8 @@ const MiniAppGalleryView: React.FC = () => { : } message={apps.length === 0 - ? '边聊边生成,马上可用。和 AI 对话生成第一个小应用吧。' - : '没有匹配的应用。'} + ? t('miniApps.emptyFirstApp') + : t('miniApps.emptyNoMatch')} /> ); } @@ -203,17 +205,17 @@ const MiniAppGalleryView: React.FC = () => { return ( - + @@ -222,7 +224,7 @@ const MiniAppGalleryView: React.FC = () => { className="gallery-action-btn" onClick={handleRefresh} disabled={loading} - title="刷新列表" + title={t('miniApps.refreshList')} > {
0 ? {runningApps.length} : null} > {runningApps.length > 0 ? ( @@ -255,13 +257,13 @@ const MiniAppGalleryView: React.FC = () => { ) : (
- 暂无运行中的应用 + {t('miniApps.noRunningApps')}
)}
{categories.length > 1 ? ( @@ -278,12 +280,12 @@ const MiniAppGalleryView: React.FC = () => { .join(' ')} onClick={() => setCategoryFilter(category)} > - {category === 'all' ? '全部' : category} + {category === 'all' ? t('miniApps.categories.all') : category} ))}
) : null} - {filtered.length} 个 + {t('miniApps.count', { count: filtered.length })} )} > @@ -305,16 +307,16 @@ const MiniAppGalleryView: React.FC = () => { {runningIdSet.has(selectedApp.id) ? ( ) : null} ) : null} @@ -335,12 +337,12 @@ const MiniAppGalleryView: React.FC = () => { isOpen={pendingDeleteId !== null} onClose={() => setPendingDeleteId(null)} onConfirm={handleDeleteConfirm} - title={`删除 "${apps.find((app) => app.id === pendingDeleteId)?.name ?? ''}"?`} - message="此操作不可撤销,应用及其所有数据将被永久删除。" + title={t('miniApps.deleteDialog.title', { name: apps.find((app) => app.id === pendingDeleteId)?.name ?? '' })} + message={t('miniApps.deleteDialog.message')} type="warning" confirmDanger - confirmText="删除" - cancelText="取消" + confirmText={t('actions.delete')} + cancelText={t('actions.cancel')} />
); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 741340b5..72e19a3d 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -780,5 +780,25 @@ "timeOfDay": "Messages by Time of Day", "lines": "Lines", "files": "Files" + }, + "miniApps": { + "subtitle": "Instant mini apps you can open and use right away, then keep iterating.", + "searchPlaceholder": "Search mini apps...", + "importFolderTitle": "Select a mini app folder (must contain meta.json and source/)", + "importFromFolder": "Import from folder", + "refreshList": "Refresh list", + "runningTitle": "Running", + "noRunningApps": "No running apps", + "allAppsTitle": "All Apps", + "emptyFirstApp": "Create your first mini app by chatting with AI and use it right away.", + "emptyNoMatch": "No matching apps.", + "count": "{{count}} items", + "categories": { + "all": "All" + }, + "deleteDialog": { + "title": "Delete \"{{name}}\"?", + "message": "This action cannot be undone. The app and all its data will be permanently deleted." + } } } diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 73ad8037..32e0f95c 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -780,5 +780,25 @@ "timeOfDay": "按时段分布", "lines": "行", "files": "文件" + }, + "miniApps": { + "subtitle": "即时生成的小应用,打开就能用,也能继续迭代。", + "searchPlaceholder": "搜索小应用...", + "importFolderTitle": "选择小应用目录(需包含 meta.json 与 source/)", + "importFromFolder": "从文件夹导入", + "refreshList": "刷新列表", + "runningTitle": "已启动", + "noRunningApps": "暂无运行中的应用", + "allAppsTitle": "全部应用", + "emptyFirstApp": "边聊边生成,马上可用。和 AI 对话生成第一个小应用吧。", + "emptyNoMatch": "没有匹配的应用。", + "count": "{{count}} 个", + "categories": { + "all": "全部" + }, + "deleteDialog": { + "title": "删除 \"{{name}}\"?", + "message": "此操作不可撤销,应用及其所有数据将被永久删除。" + } } } diff --git a/tests/e2e/config/capabilities.ts b/tests/e2e/config/capabilities.ts index b22c3a59..63b7e8bf 100644 --- a/tests/e2e/config/capabilities.ts +++ b/tests/e2e/config/capabilities.ts @@ -12,15 +12,15 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** - * Get the application path based on the current platform + * Get the application path based on the current platform. + * Defaults to debug for faster dev iteration. */ -export function getApplicationPath(buildType: 'debug' | 'release' = 'release'): string { +export function getApplicationPath(buildType: 'debug' | 'release' = 'debug'): string { const isWindows = process.platform === 'win32'; const isMac = process.platform === 'darwin'; - const isLinux = process.platform === 'linux'; - + let appName: string; - + if (isWindows) { appName = 'bitfun-desktop.exe'; } else if (isMac) { @@ -28,7 +28,7 @@ export function getApplicationPath(buildType: 'debug' | 'release' = 'release'): } else { appName = 'bitfun-desktop'; } - + return path.resolve(__dirname, '..', '..', '..', 'target', buildType, appName); } @@ -38,12 +38,9 @@ export function getApplicationPath(buildType: 'debug' | 'release' = 'release'): export const windowsCapabilities = { browserName: 'wry', 'tauri:options': { - application: getApplicationPath('release'), - }, - // Edge WebDriver specific options if needed - 'ms:edgeOptions': { - // Edge options for WebView2 + application: getApplicationPath(), }, + 'ms:edgeOptions': {}, }; /** @@ -52,12 +49,9 @@ export const windowsCapabilities = { export const linuxCapabilities = { browserName: 'wry', 'tauri:options': { - application: getApplicationPath('release'), - }, - // WebKitWebDriver specific options if needed - 'webkit:browserOptions': { - // WebKit options + application: getApplicationPath(), }, + 'webkit:browserOptions': {}, }; /** @@ -67,7 +61,7 @@ export const linuxCapabilities = { export const macOSCapabilities = { browserName: 'wry', 'tauri:options': { - application: getApplicationPath('release'), + application: getApplicationPath(), }, }; diff --git a/tests/e2e/config/tauri-wdio.d.ts b/tests/e2e/config/tauri-wdio.d.ts new file mode 100644 index 00000000..4c4a8bcf --- /dev/null +++ b/tests/e2e/config/tauri-wdio.d.ts @@ -0,0 +1,9 @@ +declare namespace WebdriverIO { + interface TauriOptions { + application: string; + } + + interface Capabilities { + 'tauri:options'?: TauriOptions; + } +} diff --git a/tests/e2e/config/wdio.conf.ts b/tests/e2e/config/wdio.conf.ts index ec4b9567..df75be07 100644 --- a/tests/e2e/config/wdio.conf.ts +++ b/tests/e2e/config/wdio.conf.ts @@ -42,13 +42,20 @@ function getTauriDriverPath(): string { return path.join(homeDir, '.cargo', 'bin', driverName); } -/** Get the path to the built Tauri application. Prefer release build; fall back to debug (requires dev server). */ +/** + * Get the path to the built Tauri application. + * + * Resolution order: + * 1. BITFUN_E2E_APP_PATH – explicit full path + * 2. BITFUN_E2E_APP_MODE – "debug" | "release" + * 3. Auto-detect: debug first (fast build), then release + */ function getApplicationPath(): string { const isWindows = process.platform === 'win32'; const appName = isWindows ? 'bitfun-desktop.exe' : 'bitfun-desktop'; const projectRoot = path.resolve(__dirname, '..', '..', '..'); - const releasePath = path.join(projectRoot, 'target', 'release', appName); const debugPath = path.join(projectRoot, 'target', 'debug', appName); + const releasePath = path.join(projectRoot, 'target', 'release', appName); const forcedPath = process.env.BITFUN_E2E_APP_PATH; const forcedMode = process.env.BITFUN_E2E_APP_MODE?.toLowerCase(); @@ -64,11 +71,11 @@ function getApplicationPath(): string { return releasePath; } - if (fs.existsSync(releasePath)) { - return releasePath; + if (fs.existsSync(debugPath)) { + return debugPath; } - return debugPath; + return releasePath; } /** @@ -146,7 +153,8 @@ export const config: Options.Testrunner = { if (!fs.existsSync(appPath)) { console.error(`Application not found at: ${appPath}`); console.error('Please build the application first with:'); - console.error('pnpm run desktop:build'); + console.error(' pnpm run desktop:build:fast (debug, fast compile, recommended for dev)'); + console.error(' pnpm run desktop:build (release, slow compile)'); throw new Error('Application not built'); } console.log(`application: ${appPath}`); diff --git a/tests/e2e/config/wdio.conf_l0.ts b/tests/e2e/config/wdio.conf_l0.ts index f31d8e27..4a03a960 100644 --- a/tests/e2e/config/wdio.conf_l0.ts +++ b/tests/e2e/config/wdio.conf_l0.ts @@ -42,16 +42,40 @@ function getTauriDriverPath(): string { return path.join(homeDir, '.cargo', 'bin', driverName); } -/** Get the path to the built Tauri application. Prefer release build; fall back to debug (requires dev server). */ +/** + * Get the path to the built Tauri application. + * + * Resolution order: + * 1. BITFUN_E2E_APP_PATH – explicit full path + * 2. BITFUN_E2E_APP_MODE – "debug" | "release" + * 3. Auto-detect: debug first (fast build), then release + */ function getApplicationPath(): string { const isWindows = process.platform === 'win32'; const appName = isWindows ? 'bitfun-desktop.exe' : 'bitfun-desktop'; const projectRoot = path.resolve(__dirname, '..', '..', '..'); + const debugPath = path.join(projectRoot, 'target', 'debug', appName); const releasePath = path.join(projectRoot, 'target', 'release', appName); - if (fs.existsSync(releasePath)) { + const forcedPath = process.env.BITFUN_E2E_APP_PATH; + const forcedMode = process.env.BITFUN_E2E_APP_MODE?.toLowerCase(); + + if (forcedPath) { + return forcedPath; + } + + if (forcedMode === 'debug') { + return debugPath; + } + + if (forcedMode === 'release') { return releasePath; } - return path.join(projectRoot, 'target', 'debug', appName); + + if (fs.existsSync(debugPath)) { + return debugPath; + } + + return releasePath; } /** @@ -139,7 +163,8 @@ export const config: Options.Testrunner = { if (!fs.existsSync(appPath)) { console.error(`Application not found at: ${appPath}`); console.error('Please build the application first with:'); - console.error('pnpm run desktop:build'); + console.error(' pnpm run desktop:build:fast (debug, fast compile, recommended for dev)'); + console.error(' pnpm run desktop:build (release, slow compile)'); throw new Error('Application not built'); } console.log(`application: ${appPath}`); diff --git a/tests/e2e/config/wdio.conf_l1.ts b/tests/e2e/config/wdio.conf_l1.ts index 4e7690dd..a970e90d 100644 --- a/tests/e2e/config/wdio.conf_l1.ts +++ b/tests/e2e/config/wdio.conf_l1.ts @@ -42,16 +42,40 @@ function getTauriDriverPath(): string { return path.join(homeDir, '.cargo', 'bin', driverName); } -/** Get the path to the built Tauri application. Prefer release build; fall back to debug (requires dev server). */ +/** + * Get the path to the built Tauri application. + * + * Resolution order: + * 1. BITFUN_E2E_APP_PATH – explicit full path + * 2. BITFUN_E2E_APP_MODE – "debug" | "release" + * 3. Auto-detect: debug first (fast build), then release + */ function getApplicationPath(): string { const isWindows = process.platform === 'win32'; const appName = isWindows ? 'bitfun-desktop.exe' : 'bitfun-desktop'; const projectRoot = path.resolve(__dirname, '..', '..', '..'); + const debugPath = path.join(projectRoot, 'target', 'debug', appName); const releasePath = path.join(projectRoot, 'target', 'release', appName); - if (fs.existsSync(releasePath)) { + const forcedPath = process.env.BITFUN_E2E_APP_PATH; + const forcedMode = process.env.BITFUN_E2E_APP_MODE?.toLowerCase(); + + if (forcedPath) { + return forcedPath; + } + + if (forcedMode === 'debug') { + return debugPath; + } + + if (forcedMode === 'release') { return releasePath; } - return path.join(projectRoot, 'target', 'debug', appName); + + if (fs.existsSync(debugPath)) { + return debugPath; + } + + return releasePath; } /** @@ -142,7 +166,8 @@ export const config: Options.Testrunner = { if (!fs.existsSync(appPath)) { console.error(`Application not found at: ${appPath}`); console.error('Please build the application first with:'); - console.error('pnpm run desktop:build'); + console.error(' pnpm run desktop:build:fast (debug, fast compile, recommended for dev)'); + console.error(' pnpm run desktop:build (release, slow compile)'); throw new Error('Application not built'); } console.log(`application: ${appPath}`); diff --git a/tests/e2e/specs/l0-miniapps-i18n.spec.ts b/tests/e2e/specs/l0-miniapps-i18n.spec.ts new file mode 100644 index 00000000..3f4bdc53 --- /dev/null +++ b/tests/e2e/specs/l0-miniapps-i18n.spec.ts @@ -0,0 +1,227 @@ +/** + * L0 mini apps i18n spec: reproduces the bug where Mini Apps stays Chinese + * even after the app language is switched to English. + */ + +import { browser, expect, $, $$ } from '@wdio/globals'; +import { saveStepScreenshot, saveElementScreenshot } from '../helpers/screenshot-utils'; + +const CHINESE_TEXT_RE = /[\u4e00-\u9fff]/; + +async function isWorkspaceOpen(): Promise { + const navPanel = await $('.bitfun-nav-panel'); + return navPanel.isExisting(); +} + +async function ensureWorkspaceOpen(): Promise { + if (await isWorkspaceOpen()) { + return; + } + + const recentItems = await $$('.welcome-scene__recent-item'); + const recentItemCount = await recentItems.length; + if (recentItemCount === 0) { + throw new Error('No open workspace and no recent workspace entry was found.'); + } + + await recentItems[0].click(); + await browser.waitUntil( + async () => isWorkspaceOpen(), + { + timeout: 20000, + timeoutMsg: 'Workspace did not open from the recent workspace list.', + } + ); +} + +async function openSettings(): Promise { + const moreButton = await $('.bitfun-nav-panel__footer-btn--icon'); + await moreButton.waitForClickable({ timeout: 10000 }); + await moreButton.click(); + + await browser.waitUntil( + async () => (await (await $$('.bitfun-nav-panel__footer-menu-item')).length) > 0, + { + timeout: 5000, + timeoutMsg: 'Footer menu items did not appear.', + } + ); + + const menuItems = await $$('.bitfun-nav-panel__footer-menu-item'); + for (const item of menuItems) { + const text = (await item.getText()).trim(); + const html = await item.getHTML(); + const ariaLabel = (await item.getAttribute('aria-label')) || ''; + if ( + text.includes('Settings') || + text.includes('设置') || + html.includes('Settings') || + html.includes('settings') || + html.includes('设置') || + ariaLabel.includes('Settings') || + ariaLabel.includes('设置') + ) { + await item.click(); + await browser.waitUntil( + async () => (await $('.bitfun-settings-scene')).isExisting(), + { + timeout: 10000, + timeoutMsg: 'Settings scene did not open.', + } + ); + return; + } + } + + throw new Error('Could not find the Settings menu item.'); +} + +async function openAppearanceTab(): Promise { + const navItems = await $$('.bitfun-settings-nav__item'); + for (const item of navItems) { + const text = (await item.getText()).trim(); + if ( + text.includes('Appearance') || + text.includes('Theme') || + text.includes('外观') || + text.includes('主题') + ) { + await item.click(); + await browser.waitUntil( + async () => (await $('.theme-config__language-select .select__trigger')).isExisting(), + { + timeout: 10000, + timeoutMsg: 'Appearance tab did not render the language selector.', + } + ); + return; + } + } + + throw new Error('Could not find the Appearance tab in settings.'); +} + +async function switchLanguageToEnglish(): Promise { + await openSettings(); + await openAppearanceTab(); + + const languageSelect = await $('.theme-config__language-select'); + const currentValueLabel = await languageSelect.$('.select__value-label'); + const hasCurrentLabel = await currentValueLabel.isExisting(); + const currentLabel = hasCurrentLabel ? (await currentValueLabel.getText()).trim() : ''; + + if (currentLabel !== 'English') { + const trigger = await languageSelect.$('.select__trigger'); + await trigger.waitForClickable({ timeout: 10000 }); + await trigger.click(); + + await browser.waitUntil( + async () => (await (await $$('.select__option')).length) > 0, + { + timeout: 5000, + timeoutMsg: 'Language options did not appear.', + } + ); + + const options = await $$('.select__option'); + for (const option of options) { + const text = (await option.getText()).trim(); + if (text.includes('English')) { + await option.click(); + break; + } + } + } + + await browser.waitUntil( + async () => { + const lang = await browser.execute(() => document.documentElement.lang); + return lang === 'en-US'; + }, + { + timeout: 15000, + timeoutMsg: 'App language did not switch to English.', + } + ); + + await browser.waitUntil( + async () => { + const settingsTitle = await $('.bitfun-settings-nav__title'); + return (await settingsTitle.getText()).trim().includes('Settings'); + }, + { + timeout: 10000, + timeoutMsg: 'Settings page did not update to English after language switch.', + } + ); +} + +async function openMiniAppsPage(): Promise { + const miniAppsEntry = await $('.bitfun-nav-panel__miniapp-entry'); + await miniAppsEntry.waitForExist({ timeout: 10000 }); + await browser.execute((element: HTMLElement) => { + element.scrollIntoView({ block: 'nearest' }); + element.click(); + }, miniAppsEntry); + + await browser.waitUntil( + async () => (await $('.miniapp-gallery .gallery-page-header__title')).isExisting(), + { + timeout: 10000, + timeoutMsg: 'Mini Apps gallery did not open.', + } + ); +} + +async function getMiniAppsCopy() { + const title = await $('.miniapp-gallery .gallery-page-header__title'); + const subtitle = await $('.miniapp-gallery .gallery-page-header__subtitle'); + const searchInput = await $('.miniapp-gallery .search__input'); + const zoneTitleEls = await $$('.miniapp-gallery .gallery-zone__title'); + const actionButtons = await $$('.miniapp-gallery .gallery-action-btn'); + + const zoneTitles: string[] = []; + for (const element of zoneTitleEls) { + zoneTitles.push((await element.getText()).trim()); + } + + const actionTitles: string[] = []; + for (const button of actionButtons) { + const titleAttr = await button.getAttribute('title'); + if (titleAttr) { + actionTitles.push(titleAttr.trim()); + } + } + + return { + lang: await browser.execute(() => document.documentElement.lang), + title: (await title.getText()).trim(), + subtitle: (await subtitle.getText()).trim(), + searchPlaceholder: ((await searchInput.getAttribute('placeholder')) || '').trim(), + zoneTitles, + actionTitles, + }; +} + +describe('L0 Mini Apps i18n', () => { + it('should show Mini Apps page text in English when app language is English', async () => { + await browser.pause(3000); + await ensureWorkspaceOpen(); + await switchLanguageToEnglish(); + await openMiniAppsPage(); + + const copy = await getMiniAppsCopy(); + console.log('[L0] Mini Apps copy snapshot:', copy); + + await saveStepScreenshot('miniapps-english-mode-full-page'); + await saveStepScreenshot('miniapps-english-mode-bug'); + await saveElementScreenshot('.miniapp-gallery', 'miniapps-english-mode-gallery'); + + expect(copy.lang).toBe('en-US'); + expect(copy.title).not.toMatch(CHINESE_TEXT_RE); + expect(copy.subtitle).not.toMatch(CHINESE_TEXT_RE); + expect(copy.searchPlaceholder).not.toMatch(CHINESE_TEXT_RE); + expect(copy.zoneTitles.join(' | ')).not.toMatch(CHINESE_TEXT_RE); + expect(copy.actionTitles.join(' | ')).not.toMatch(CHINESE_TEXT_RE); + }); +});