diff --git a/OpenBitFun-Installer/AGENTS.md b/OpenBitFun-Installer/AGENTS.md index b3c58298fc..efd0908199 100644 --- a/OpenBitFun-Installer/AGENTS.md +++ b/OpenBitFun-Installer/AGENTS.md @@ -48,6 +48,7 @@ Use the smallest matching check: pnpm run i18n:audit # resource-only i18n pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit pnpm --dir OpenBitFun-Installer run type-check # frontend i18n/runtime +pnpm --dir OpenBitFun-Installer run test # frontend control interactions cargo check --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml # Tauri/Rust changes ``` diff --git a/OpenBitFun-Installer/index.html b/OpenBitFun-Installer/index.html index 600ee6d262..eb57e70ac9 100644 --- a/OpenBitFun-Installer/index.html +++ b/OpenBitFun-Installer/index.html @@ -8,7 +8,6 @@ Install OpenBitFun - diff --git a/OpenBitFun-Installer/package.json b/OpenBitFun-Installer/package.json index c11f11c7d8..667dfc0322 100644 --- a/OpenBitFun-Installer/package.json +++ b/OpenBitFun-Installer/package.json @@ -26,7 +26,8 @@ "installer:build:only": "node scripts/build-installer.cjs --skip-app-build", "installer:build:only:fast": "node scripts/build-installer.cjs --skip-app-build --mode fast", "installer:dev": "node scripts/build-installer.cjs --dev", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run src" }, "dependencies": { "@openbitfun/theme-openbitfun": "workspace:^", @@ -46,6 +47,8 @@ "@vitejs/plugin-react": "^4.6.0", "terser": "^5.46.0", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^4.1.0", + "jsdom": "^29.0.1" } } diff --git a/OpenBitFun-Installer/src/components/WindowControls.tsx b/OpenBitFun-Installer/src/components/WindowControls.tsx index 2901346276..334eaf49b4 100644 --- a/OpenBitFun-Installer/src/components/WindowControls.tsx +++ b/OpenBitFun-Installer/src/components/WindowControls.tsx @@ -1,3 +1,4 @@ +import { IconButton } from '@openbitfun/ui'; import { Minus as LucideMinus, X as LucideX } from 'lucide-react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { useTranslation } from 'react-i18next'; @@ -18,22 +19,20 @@ export function WindowControls() { return (
- - + icon={
); } diff --git a/OpenBitFun-Installer/src/main.tsx b/OpenBitFun-Installer/src/main.tsx index 22618acbea..3cbb53b265 100644 --- a/OpenBitFun-Installer/src/main.tsx +++ b/OpenBitFun-Installer/src/main.tsx @@ -1,3 +1,4 @@ +import './theme/installerThemeBootstrap'; import React from 'react'; import ReactDOM from 'react-dom/client'; import './i18n'; diff --git a/OpenBitFun-Installer/src/pages/ModelSetup.test.tsx b/OpenBitFun-Installer/src/pages/ModelSetup.test.tsx new file mode 100644 index 0000000000..61c50fab8e --- /dev/null +++ b/OpenBitFun-Installer/src/pages/ModelSetup.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ModelSetup } from './ModelSetup'; +import { PROVIDER_TEMPLATES, createModelConfigFromTemplate } from '../data/modelProviders'; +import type { InstallOptions, RemoteModelInfo } from '../types/installer'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue || key }), +})); +const initial: InstallOptions = { + installPath: 'D:\\OpenBitFun', desktopShortcut: false, startMenu: true, + launchAfterInstall: false, appLanguage: 'en-US', themePreference: 'system', modelConfig: null, +}; +let container: HTMLDivElement; +let root: ReturnType; +let changed = vi.fn(); +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + changed = vi.fn(); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); +async function render(options = initial, previewOnly = false) { + await act(async () => root.render()); +} +function trigger(label: string) { + return container.querySelector(`button[role="combobox"][aria-label="${label}"]`)!; +} +function option(label: string) { + return [...document.querySelectorAll('[role="option"]')] + .find(item => item.getAttribute('aria-label') === label)!; +} + +it('uses the shared popup for provider selection and restores focus after Escape', async () => { + await render(); + const provider = trigger('model.providerLabel'); + expect(provider.closest('[data-openbitfun-component="select"]')).not.toBeNull(); + await act(async () => provider.click()); + expect(document.querySelector('[data-openbitfun-component="select-popup"]')).not.toBeNull(); + await act(async () => option('model.customProvider').click()); + expect(trigger('model.form.provider')).not.toBeNull(); + expect(changed.mock.lastCall![0](initial).modelConfig.provider).toBe('custom'); + await act(async () => trigger('model.form.provider').click()); + await act(async () => option('model.formats.responsesApi').click()); + expect(changed.mock.lastCall![0](initial).modelConfig.format).toBe('responses'); + await act(async () => provider.click()); + await act(async () => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(document.querySelector('[data-openbitfun-component="select-popup"]')).toBeNull(); + expect(document.activeElement).toBe(provider); +}); + +it('retains endpoint notes and applies the selected endpoint format', async () => { + const template = Object.values(PROVIDER_TEMPLATES).find(item => (item.baseUrlOptions?.length ?? 0) > 1)!; + await render({ ...initial, modelConfig: createModelConfigFromTemplate(template, null) }); + await act(async () => trigger('model.endpointPreset').click()); + const endpoint = template.baseUrlOptions![1]; + const label = endpoint.noteKey?.split('.').pop() || endpoint.format.toUpperCase(); + const item = option(label); + expect(item.textContent).toContain(label); + await act(async () => item.click()); + const config = changed.mock.lastCall![0](initial).modelConfig; + expect(config.baseUrl).toBe(endpoint.url); + expect(config.format).toBe(endpoint.format); +}); + +it('keeps the model popup open while loading and lets the returned model be selected', async () => { + let resolve!: (models: RemoteModelInfo[]) => void; + mocks.invoke.mockReturnValue(new Promise(done => { resolve = done; })); + const modelConfig = { ...createModelConfigFromTemplate(PROVIDER_TEMPLATES.deepseek, null), apiKey: 'test-key' }; + await render({ ...initial, modelConfig }); + await act(async () => trigger('model.form.modelSelection').click()); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(document.querySelector('[data-openbitfun-component="select-popup"]')).not.toBeNull(); + await act(async () => resolve([{ id: 'new-remote-model' }])); + await act(async () => option('new-remote-model').click()); + expect(changed.mock.lastCall![0](initial).modelConfig.modelName).toBe('new-remote-model'); +}); + +it('keeps native preview from fetching remote models', async () => { + const modelConfig = { ...createModelConfigFromTemplate(PROVIDER_TEMPLATES.deepseek, null), apiKey: 'test-key' }; + await render({ ...initial, modelConfig }, true); + await act(async () => trigger('model.form.modelSelection').click()); + expect(document.querySelector('[data-openbitfun-component="select-popup"]')).not.toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalled(); +}); diff --git a/OpenBitFun-Installer/src/pages/ModelSetup.tsx b/OpenBitFun-Installer/src/pages/ModelSetup.tsx index b8f3406e6f..f37cb7215a 100644 --- a/OpenBitFun-Installer/src/pages/ModelSetup.tsx +++ b/OpenBitFun-Installer/src/pages/ModelSetup.tsx @@ -297,6 +297,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti value={selectedProviderId} options={providerOptions} placeholder={t('model.selectProvider')} + aria-label={t('model.providerLabel')} onValueChange={(value) => handleProviderSelect(String(value))} /> @@ -340,6 +341,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti value={template?.baseUrlOptions?.some((option) => option.url === effectiveBaseUrl) ? effectiveBaseUrl : ''} options={baseUrlOptions} placeholder={t('model.baseUrlPlaceholder')} + aria-label={t('model.endpointPreset')} onValueChange={(value) => handleBaseUrlOptionSelect(String(value))} /> @@ -351,8 +353,9 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti value={modelSelectionValue} options={modelOptions} placeholder={t('model.modelNameSelectPlaceholder')} - disabled={isFetchingRemoteModels} - onOpenChange={(open) => { if (open && !previewOnly) void fetchRemoteModels(); }} + aria-label={t('model.form.modelSelection')} + aria-busy={isFetchingRemoteModels} + onOpenChange={(open) => { if (open && !previewOnly && !isFetchingRemoteModels) void fetchRemoteModels(); }} onValueChange={(value) => { const next = String(value); if (next === CUSTOM_MODEL_OPTION) { @@ -413,6 +416,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti value={isCustomProvider ? customFormat : apiFormat} options={formatSelectOptions} placeholder={t('model.form.providerPlaceholder')} + aria-label={t('model.form.provider')} onValueChange={(value) => { const format = value as RequestFormatValue; if (isCustomProvider) setCustomFormat(format); diff --git a/OpenBitFun-Installer/src/pages/Options.test.tsx b/OpenBitFun-Installer/src/pages/Options.test.tsx new file mode 100644 index 0000000000..fa46f01abf --- /dev/null +++ b/OpenBitFun-Installer/src/pages/Options.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { Options } from './Options'; +import { ThemeSetup } from './ThemeSetup'; +import { WindowControls } from '../components/WindowControls'; +import type { InstallOptions } from '../types/installer'; + +const mocks = vi.hoisted(() => ({ minimize: vi.fn(), close: vi.fn() })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: vi.fn() })); +vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => mocks })); + +const initial: InstallOptions = { + installPath: 'D:\\OpenBitFun', desktopShortcut: false, startMenu: true, + launchAfterInstall: false, appLanguage: 'en-US', themePreference: 'system', modelConfig: null, +}; +let container: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +it('updates an install option once per label or native input click and keeps the path editable', async () => { + const changed = vi.fn(); + const clearError = vi.fn(); + const refreshDiskSpace = vi.fn().mockResolvedValue(undefined); + function Page() { + const [options, setOptions] = useState(initial); + return { changed(value); setOptions(value); }} + diskSpace={null} error={null} refreshDiskSpace={refreshDiskSpace} + existingInstall={null} onLaunchRegisteredUninstaller={vi.fn()} onBack={vi.fn()} + onInstall={vi.fn()} isInstalling={false} clearInstallError={clearError} />; + } + await act(async () => root.render()); + const label = container.querySelector('[data-openbitfun-component="checkbox"]')!; + const checkbox = label.querySelector('input')!; + expect(checkbox.checked).toBe(false); + act(() => label.click()); + expect(checkbox.checked).toBe(true); + expect(changed).toHaveBeenCalledTimes(1); + act(() => checkbox.click()); + expect(checkbox.checked).toBe(false); + expect(changed).toHaveBeenCalledTimes(2); + act(() => checkbox.focus()); + expect(document.activeElement).toBe(checkbox); + expect(checkbox.tabIndex).toBe(0); + + const path = container.querySelector('[data-openbitfun-component="input"] input')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(path, 'D:\\Apps\\OpenBitFun'); + path.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(path.value).toBe('D:\\Apps\\OpenBitFun'); + expect(clearError).toHaveBeenCalledTimes(1); + expect(refreshDiskSpace).toHaveBeenLastCalledWith('D:\\Apps\\OpenBitFun'); +}); + +it('keeps path and install/navigation actions disabled during installation', async () => { + const install = vi.fn(); + await act(async () => root.render()); + expect(container.querySelector('input[type="text"]')!.disabled).toBe(true); + const buttons = [...container.querySelectorAll('button')]; + expect(buttons).toHaveLength(3); + for (const button of buttons) { + expect(button.disabled).toBe(true); + expect(button.dataset.openbitfunComponent).toBe('button'); + act(() => button.click()); + } + expect(install).not.toHaveBeenCalled(); +}); + +it('connects launch-after-install on the theme page and preserves window control handlers', async () => { + const changed = vi.fn(); + await act(async () => root.render(<>)); + const label = container.querySelector('[data-openbitfun-component="checkbox"]')!; + act(() => label.click()); + expect(changed).toHaveBeenCalledTimes(1); + expect(changed.mock.calls[0][0](initial).launchAfterInstall).toBe(true); + const buttons = [...container.querySelectorAll('[data-openbitfun-component="icon-button"]')]; + act(() => buttons[0].click()); + act(() => buttons[1].click()); + expect(mocks.minimize).toHaveBeenCalledTimes(1); + expect(mocks.close).toHaveBeenCalledTimes(1); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c4ca0fff2..5e405d8230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + jsdom: + specifier: ^29.0.1 + version: 29.0.1(@noble/hashes@2.0.1) terser: specifier: ^5.46.0 version: 5.46.0 @@ -108,6 +111,9 @@ importers: vite: specifier: ^7.0.4 version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@22.19.7)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) design-system: {} @@ -222,6 +228,9 @@ importers: '@openbitfun/theme-openbitfun': specifier: workspace:^ version: link:../../design-system/packages/theme-openbitfun + '@openbitfun/ui': + specifier: workspace:^ + version: link:../../design-system/packages/ui '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -335,6 +344,9 @@ importers: '@openbitfun/theme-openbitfun': specifier: workspace:^ version: link:../../design-system/packages/theme-openbitfun + '@openbitfun/ui': + specifier: workspace:^ + version: link:../../design-system/packages/ui '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/src/apps/miniapp-market-server/Dockerfile b/src/apps/miniapp-market-server/Dockerfile index a3f7d936bb..22e6625132 100644 --- a/src/apps/miniapp-market-server/Dockerfile +++ b/src/apps/miniapp-market-server/Dockerfile @@ -8,6 +8,8 @@ RUN corepack enable && corepack prepare pnpm@10.15.0 --activate COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY patches/ patches/ COPY design-system/LICENSE design-system/LICENSE +COPY design-system/tsconfig.base.json design-system/tsconfig.base.json +COPY design-system/packages/ui/package.json design-system/packages/ui/package.json COPY design-system/tooling/token-engine/package.json design-system/tooling/token-engine/package.json COPY design-system/packages/design-tokens/package.json design-system/packages/design-tokens/package.json COPY design-system/packages/theme-openbitfun/package.json design-system/packages/theme-openbitfun/package.json @@ -15,12 +17,14 @@ COPY src/miniapp-market-web/package.json src/miniapp-market-web/package.json RUN --mount=type=cache,id=openbitfun-market-pnpm,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile --ignore-scripts --filter openbitfun-miniapp-market-web... +COPY design-system/packages/ui/ design-system/packages/ui/ COPY design-system/tooling/token-engine/ design-system/tooling/token-engine/ COPY design-system/packages/design-tokens/ design-system/packages/design-tokens/ COPY design-system/packages/theme-openbitfun/ design-system/packages/theme-openbitfun/ COPY src/miniapp-market-web/ src/miniapp-market-web/ RUN pnpm --dir design-system/packages/design-tokens build \ && pnpm --dir design-system/packages/theme-openbitfun build \ + && pnpm --dir design-system/packages/ui build \ && pnpm --dir src/miniapp-market-web build FROM rust:1.96-bookworm AS rust-builder diff --git a/src/apps/miniapp-market-server/Dockerfile.dockerignore b/src/apps/miniapp-market-server/Dockerfile.dockerignore index 9d77f42167..bfe9f881de 100644 --- a/src/apps/miniapp-market-server/Dockerfile.dockerignore +++ b/src/apps/miniapp-market-server/Dockerfile.dockerignore @@ -11,6 +11,9 @@ !design-system/ !design-system/LICENSE +!design-system/tsconfig.base.json +!design-system/packages/ui/ +!design-system/packages/ui/** !design-system/packages/ !design-system/packages/design-tokens/ !design-system/packages/design-tokens/package.json diff --git a/src/apps/skin-market-server/Dockerfile b/src/apps/skin-market-server/Dockerfile index 108a00b790..50f91b23e3 100644 --- a/src/apps/skin-market-server/Dockerfile +++ b/src/apps/skin-market-server/Dockerfile @@ -8,6 +8,8 @@ RUN corepack enable && corepack prepare pnpm@10.15.0 --activate COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY patches/ patches/ COPY design-system/LICENSE design-system/LICENSE +COPY design-system/tsconfig.base.json design-system/tsconfig.base.json +COPY design-system/packages/ui/package.json design-system/packages/ui/package.json COPY design-system/tooling/token-engine/package.json design-system/tooling/token-engine/package.json COPY design-system/packages/design-tokens/package.json design-system/packages/design-tokens/package.json COPY design-system/packages/theme-openbitfun/package.json design-system/packages/theme-openbitfun/package.json @@ -15,6 +17,7 @@ COPY src/skin-market-web/package.json src/skin-market-web/package.json RUN --mount=type=cache,id=openbitfun-skin-market-pnpm,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile --ignore-scripts --filter openbitfun-skin-market-web... +COPY design-system/packages/ui/ design-system/packages/ui/ COPY design-system/tooling/token-engine/ design-system/tooling/token-engine/ COPY design-system/packages/design-tokens/ design-system/packages/design-tokens/ COPY design-system/packages/theme-openbitfun/ design-system/packages/theme-openbitfun/ @@ -22,6 +25,7 @@ COPY src/skin-market-web/ src/skin-market-web/ COPY src/shared/appearance-market-contract-fixtures/ src/shared/appearance-market-contract-fixtures/ RUN pnpm --dir design-system/packages/design-tokens build \ && pnpm --dir design-system/packages/theme-openbitfun build \ + && pnpm --dir design-system/packages/ui build \ && pnpm --dir src/skin-market-web build FROM rust:1.96-bookworm AS rust-builder diff --git a/src/apps/skin-market-server/Dockerfile.dockerignore b/src/apps/skin-market-server/Dockerfile.dockerignore index 3e642b697d..97b27b0b3b 100644 --- a/src/apps/skin-market-server/Dockerfile.dockerignore +++ b/src/apps/skin-market-server/Dockerfile.dockerignore @@ -11,6 +11,9 @@ !design-system/ !design-system/LICENSE +!design-system/tsconfig.base.json +!design-system/packages/ui/ +!design-system/packages/ui/** !design-system/packages/ !design-system/packages/design-tokens/ !design-system/packages/design-tokens/package.json diff --git a/src/miniapp-market-web/README.md b/src/miniapp-market-web/README.md index d744df87d5..a5dc03f17f 100644 --- a/src/miniapp-market-web/README.md +++ b/src/miniapp-market-web/README.md @@ -115,6 +115,7 @@ cargo run -p openbitfun-miniapp-market-server 终端二启动 Vite: ```bash +pnpm --dir design-system run build:packages # 首次运行或共享主题/UI 包变更后执行 pnpm run dev:miniapp-market ``` diff --git a/src/miniapp-market-web/package.json b/src/miniapp-market-web/package.json index 9175dfda20..0a6f2f5972 100644 --- a/src/miniapp-market-web/package.json +++ b/src/miniapp-market-web/package.json @@ -15,7 +15,8 @@ "@phosphor-icons/react": "^2.1.10", "lucide-react": "^0.541.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "@openbitfun/ui": "workspace:^" }, "devDependencies": { "@types/react": "^18.3.0", diff --git a/src/miniapp-market-web/src/App.test.tsx b/src/miniapp-market-web/src/App.test.tsx new file mode 100644 index 0000000000..5fec7edf0f --- /dev/null +++ b/src/miniapp-market-web/src/App.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import App from './App'; + +const mocks = vi.hoisted(() => ({ config: vi.fn(), me: vi.fn() })); +vi.mock('./api', async (original) => ({ + ...await original(), + marketApi: mocks, +})); + +let container: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))); + window.history.replaceState({}, '', '/miniapp/submit?listingId=listing-1&slug=my-app&release=2'); + mocks.config.mockResolvedValue({ webSubmissionsEnabled: true, githubAuthConfigured: true }); + mocks.me.mockResolvedValue({ user: { login: 'publisher', avatarUrl: '' }, isAdmin: false }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +it('preserves native submission fields and update constraints on the actual submit route', async () => { + await act(async () => root.render()); + const form = container.querySelector('form'); + expect(form).not.toBeNull(); + const data = new FormData(form!); + expect(data.get('slug')).toBe('my-app'); + expect(data.get('releaseNumber')).toBe('2'); + expect(data.get('icon')).toBe('✦'); + expect(data.get('licenseValue')).toBe('MIT'); + const slug = form!.elements.namedItem('slug') as HTMLInputElement; + const release = form!.elements.namedItem('releaseNumber') as HTMLInputElement; + expect(slug.readOnly).toBe(true); + expect(slug.pattern).toBe('[a-z0-9][a-z0-9-]{2,62}'); + expect(release.readOnly).toBe(true); + expect(release.min).toBe('1'); + expect(slug.closest('[data-openbitfun-component="input"]')).not.toBeNull(); + expect(form!.querySelectorAll('input[type="file"]')).toHaveLength(2); + expect((form!.elements.namedItem('screenshots') as HTMLInputElement).multiple).toBe(true); + expect(form!.querySelector('.submit-button')!.type).toBe('submit'); + const description = form!.elements.namedItem('description') as HTMLTextAreaElement; + expect(description.rows).toBe(3); + expect(description.maxLength).toBe(500); + expect(description.required).toBe(true); + expect(description.closest('[data-openbitfun-component="textarea"]')).not.toBeNull(); + expect(form!.checkValidity()).toBe(false); +}); + +it('keeps web submissions unavailable when the server disables them', async () => { + mocks.config.mockResolvedValue({ webSubmissionsEnabled: false }); + await act(async () => root.render()); + expect(container.querySelector('form')).toBeNull(); + expect(container.querySelector('input[type="file"]')).toBeNull(); +}); diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index 84ebd40400..a2189c630e 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -1,3 +1,4 @@ +import { Input, Textarea, Button, IconButton } from '@openbitfun/ui'; import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CubeFocus as MarketLogo, GithubLogo, IconContext } from '@phosphor-icons/react'; import { @@ -283,7 +284,7 @@ function Header({ return (
- +
- + {languageMenuOpen && (
{t('language')} {LOCALE_OPTIONS.map((localeOption) => ( - + ))}
)}
- + /> {me ? (
@{me.user.login} - + type="button" + icon={
) : (
- + {(config?.categories || MARKET_CATEGORIES).map((item) => ( - + ))}
@@ -569,10 +569,10 @@ function CatalogPage({

{t('empty')}

{config?.webSubmissionsEnabled ? ( - + ) : ( {t('desktopSubmissionHint')} )} @@ -584,10 +584,10 @@ function CatalogPage({ ))}
{cursor && ( - + )} @@ -733,10 +733,10 @@ function DetailPage({ if (error) { return (
- + {errorMessage(error, t)}
); @@ -747,10 +747,10 @@ function DetailPage({ const localized = localizedListing(app, locale); return (
- +
{categoryLabel(app.category, t)} @@ -772,7 +772,7 @@ function DetailPage({ - + {owner && webSubmissionsEnabled && ( - + )}
{[1, 2, 3, 4, 5].map((value) => ( - + type="button" + icon={} + /> ))} {app.ratingAverage.toFixed(1)} ({app.ratingCount})
@@ -900,7 +900,7 @@ function DetailPage({ {release.yanked ? t('yankedLabel') : release.changelog} {me?.isAdmin && !release.yanked && ( - + )} ))} @@ -918,12 +918,12 @@ function DetailPage({
- +
)}
@@ -1053,16 +1053,16 @@ function SubmitPage({ {t('listingSection')}
- + - + - + - + - +
-