Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OpenBitFun-Installer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 0 additions & 1 deletion OpenBitFun-Installer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
<link rel="icon" type="image/png" sizes="16x16" href="/src/assets/openbitfun-app-icon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/src/assets/openbitfun-app-icon-32.png" />
<title>Install OpenBitFun</title>
<script type="module" src="/src/theme/installerThemeBootstrap.ts"></script>
<style>
html, body { margin: 0; padding: 0; }
</style>
Expand Down
7 changes: 5 additions & 2 deletions OpenBitFun-Installer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand All @@ -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"
}
}
15 changes: 7 additions & 8 deletions OpenBitFun-Installer/src/components/WindowControls.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,22 +19,20 @@ export function WindowControls() {

return (
<div className="window-controls">
<button
<IconButton
className="window-controls__btn"
onClick={handleMinimize}
aria-label={t('window.minimize')}
title={t('window.minimize')}
>
<LucideMinus width="14" height="14" aria-hidden="true" />
</button>
<button
icon={<LucideMinus width="14" height="14" aria-hidden="true" />}
/>
<IconButton
className="window-controls__btn window-controls__btn--close"
onClick={handleClose}
aria-label={t('window.close')}
title={t('window.close')}
>
<LucideX width="14" height="14" aria-hidden="true" />
</button>
icon={<LucideX width="14" height="14" aria-hidden="true" />}
/>
</div>
);
}
1 change: 1 addition & 0 deletions OpenBitFun-Installer/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './theme/installerThemeBootstrap';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './i18n';
Expand Down
96 changes: 96 additions & 0 deletions OpenBitFun-Installer/src/pages/ModelSetup.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createRoot>;
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(<ModelSetup previewOnly={previewOnly} options={options} setOptions={changed}
onSkip={vi.fn()} onNext={vi.fn()} onTestConnection={vi.fn()} />));
}
function trigger(label: string) {
return container.querySelector<HTMLButtonElement>(`button[role="combobox"][aria-label="${label}"]`)!;
}
function option(label: string) {
return [...document.querySelectorAll<HTMLButtonElement>('[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<RemoteModelInfo[]>(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();
});
8 changes: 6 additions & 2 deletions OpenBitFun-Installer/src/pages/ModelSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))}
/>
</Field>
Expand Down Expand Up @@ -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))}
/>
</Field>
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
99 changes: 99 additions & 0 deletions OpenBitFun-Installer/src/pages/Options.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createRoot>;
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 <Options options={options} setOptions={value => { 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(<Page />));
const label = container.querySelector<HTMLLabelElement>('[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<HTMLInputElement>('[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(<Options options={initial} setOptions={vi.fn()}
diskSpace={null} error={null} refreshDiskSpace={vi.fn()} existingInstall={null}
onLaunchRegisteredUninstaller={vi.fn()} onBack={vi.fn()} onInstall={install}
isInstalling clearInstallError={vi.fn()} />));
expect(container.querySelector<HTMLInputElement>('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(<><ThemeSetup options={initial} setOptions={changed}
onLaunch={vi.fn()} onClose={vi.fn()} /><WindowControls /></>));
const label = container.querySelector<HTMLLabelElement>('[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<HTMLButtonElement>('[data-openbitfun-component="icon-button"]')];
act(() => buttons[0].click());
act(() => buttons[1].click());
expect(mocks.minimize).toHaveBeenCalledTimes(1);
expect(mocks.close).toHaveBeenCalledTimes(1);
});
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/apps/miniapp-market-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@ 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
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
Expand Down
3 changes: 3 additions & 0 deletions src/apps/miniapp-market-server/Dockerfile.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading