Skip to content
Open
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
4 changes: 3 additions & 1 deletion electron/gateway/clawhub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from 'path';
import { app, shell } from 'electron';
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';


export interface ClawHubSearchParams {
query: string;
limit?: number;
Expand Down Expand Up @@ -127,6 +128,7 @@ export class ClawHubService {
* Run a ClawHub CLI command
*/
private async runCommand(args: string[]): Promise<string> {

return new Promise((resolve, reject) => {
if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) {
reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`));
Expand All @@ -145,7 +147,7 @@ export class ClawHubService {
const isWin = process.platform === 'win32';
const useShell = isWin && !this.useNodeRunner;
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const env = {
const env: Record<string, string | undefined> = {
...baseEnv,
CI: 'true',
FORCE_COLOR: '0',
Comment on lines +150 to 153
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply zh mirror registry before spawning ClawHub CLI

The feature in this commit is not actually implemented in runtime code: runCommand still builds the child process environment with only baseline vars and CLAWHUB_WORKDIR, but never sets CLAWHUB_REGISTRY based on the UI language. That means zh users continue to use the default registry, and the newly added tests/unit/clawhub-registry.test.ts expectations cannot pass because spawn receives no registry override for zh* locales.

Useful? React with 👍 / 👎.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
},
"dependencies": {
"@sinclair/typebox": "^0.34.48",
"clawhub": "^0.5.0",
"clawhub": "^0.9.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.3",
"lru-cache": "^11.2.6",
Expand Down
29 changes: 6 additions & 23 deletions pnpm-lock.yaml

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

4 changes: 2 additions & 2 deletions src/pages/Skills/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ export function Skills() {
<div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2">
<AlertCircle className="h-5 w-5 shrink-0" />
<span>
{['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError'].includes(error)
{['fetchTimeoutError', 'fetchRateLimitError'].includes(error)
? t(`toast.${error}`, { path: skillsDirPath })
: error}
</span>
Expand Down Expand Up @@ -851,7 +851,7 @@ export function Skills() {
<div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2">
<AlertCircle className="h-5 w-5 shrink-0" />
<span>
{['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError'].includes(searchError.replace('Error: ', ''))
{['searchTimeoutError', 'searchRateLimitError'].includes(searchError.replace('Error: ', ''))
? t(`toast.${searchError.replace('Error: ', '')}`, { path: skillsDirPath })
: t('marketplace.searchError')}
</span>
Expand Down
10 changes: 5 additions & 5 deletions src/stores/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type ClawHubListResult = {
function mapErrorCodeToSkillErrorKey(
code: AppError['code'],
operation: 'fetch' | 'search' | 'install',
): string {
): string | null {
if (code === 'TIMEOUT') {
return operation === 'search'
? 'searchTimeoutError'
Expand All @@ -54,7 +54,7 @@ function mapErrorCodeToSkillErrorKey(
? 'installRateLimitError'
: 'fetchRateLimitError';
}
return 'rateLimitError';
return null;
}

interface SkillsState {
Expand Down Expand Up @@ -171,7 +171,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
} catch (error) {
console.error('Failed to fetch skills:', error);
const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' });
set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') });
set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') || appError.message });
}
},

Expand All @@ -192,7 +192,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
}
} catch (error) {
const appError = normalizeAppError(error, { module: 'skills', operation: 'search' });
set({ searchError: mapErrorCodeToSkillErrorKey(appError.code, 'search') });
set({ searchError: mapErrorCodeToSkillErrorKey(appError.code, 'search') || appError.message });
} finally {
set({ searching: false });
}
Expand All @@ -210,7 +210,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
module: 'skills',
operation: 'install',
});
throw new Error(mapErrorCodeToSkillErrorKey(appError.code, 'install'));
throw new Error(mapErrorCodeToSkillErrorKey(appError.code, 'install') || appError.message);
}
// Refresh skills after install
await get().fetchSkills();
Expand Down
182 changes: 182 additions & 0 deletions tests/unit/clawhub-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Tests for ClawHub China mirror registry switching logic.
*
* Verifies that when the app language starts with 'zh', the spawned ClawHub CLI
* process receives CLAWHUB_REGISTRY=https://mirror-cn.clawhub.com, and that
* non-Chinese locales do NOT set this variable.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'events';

/* ---------- hoisted mocks ---------- */
const {
mockGetSetting,
mockExistsSync,
mockSpawn,
mockEnsureDir,
} = vi.hoisted(() => ({
mockGetSetting: vi.fn<() => Promise<string | undefined>>(),
mockExistsSync: vi.fn<(p: string) => boolean>(),
mockSpawn: vi.fn(),
mockEnsureDir: vi.fn(),
}));

/* ---------- module mocks ---------- */
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
return { ...actual, default: { ...actual, spawn: mockSpawn }, spawn: mockSpawn };
});

vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: { ...actual, existsSync: mockExistsSync, readdirSync: vi.fn(() => []) },
existsSync: mockExistsSync,
};
});

vi.mock('electron', () => ({
app: { get isPackaged() { return true; } },
shell: { openPath: vi.fn() },
}));

vi.mock('@electron/utils/paths', () => ({
getOpenClawConfigDir: () => '/tmp/test-openclaw',
ensureDir: mockEnsureDir,
getClawHubCliBinPath: () => '/tmp/clawhub-bin',
getClawHubCliEntryPath: () => '/tmp/clawhub-entry.mjs',
quoteForCmd: (s: string) => `"${s}"`,
}));

vi.mock('@electron/utils/store', () => ({
getSetting: (...args: unknown[]) => mockGetSetting(...args),
}));

/* ---------- helpers ---------- */

/** Create a fake ChildProcess that emits stdout data then exits with code 0. */
function makeFakeChild(stdoutData: string = 'ok') {
const child = new EventEmitter() as ReturnType<typeof import('child_process').spawn>;
const stdout = new EventEmitter();
const stderr = new EventEmitter();
(child as any).stdout = stdout;

Check warning on line 63 in tests/unit/clawhub-registry.test.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected any. Specify a different type
(child as any).stderr = stderr;

Check warning on line 64 in tests/unit/clawhub-registry.test.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected any. Specify a different type

// Simulate async output + successful exit
queueMicrotask(() => {
stdout.emit('data', Buffer.from(stdoutData));
child.emit('close', 0);
});

return child;
}

/* ---------- test suite ---------- */

describe('ClawHub China mirror registry', () => {
let spawnEnvCapture: Record<string, string | undefined> | undefined;

beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();

// Default: CLI entry exists so constructor succeeds (useNodeRunner = true path)
mockExistsSync.mockReturnValue(true);

// Capture the env passed to spawn
mockSpawn.mockImplementation((_cmd: string, _args: string[], opts: any) => {

Check warning on line 88 in tests/unit/clawhub-registry.test.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected any. Specify a different type
spawnEnvCapture = opts?.env;
return makeFakeChild();
});
});

afterEach(() => {
spawnEnvCapture = undefined;
});

it('sets CLAWHUB_REGISTRY with HTTPS for zh-CN locale', async () => {
mockGetSetting.mockResolvedValueOnce('zh-CN');

const { ClawHubService } = await import('@electron/gateway/clawhub');
const service = new ClawHubService();

// Use search as the trigger to invoke runCommand
await service.search({ query: 'test' });

expect(spawnEnvCapture).toBeDefined();
expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBe('https://mirror-cn.clawhub.com');

Check failure on line 108 in tests/unit/clawhub-registry.test.ts

View workflow job for this annotation

GitHub Actions / check

tests/unit/clawhub-registry.test.ts > ClawHub China mirror registry > sets CLAWHUB_REGISTRY with HTTPS for zh-CN locale

AssertionError: expected undefined to be 'https://mirror-cn.clawhub.com' // Object.is equality - Expected: "https://mirror-cn.clawhub.com" + Received: undefined ❯ tests/unit/clawhub-registry.test.ts:108:47
});

it('sets CLAWHUB_REGISTRY with HTTPS for zh-TW locale', async () => {
mockGetSetting.mockResolvedValueOnce('zh-TW');

const { ClawHubService } = await import('@electron/gateway/clawhub');
const service = new ClawHubService();

await service.search({ query: 'test' });

expect(spawnEnvCapture).toBeDefined();
expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBe('https://mirror-cn.clawhub.com');

Check failure on line 120 in tests/unit/clawhub-registry.test.ts

View workflow job for this annotation

GitHub Actions / check

tests/unit/clawhub-registry.test.ts > ClawHub China mirror registry > sets CLAWHUB_REGISTRY with HTTPS for zh-TW locale

AssertionError: expected undefined to be 'https://mirror-cn.clawhub.com' // Object.is equality - Expected: "https://mirror-cn.clawhub.com" + Received: undefined ❯ tests/unit/clawhub-registry.test.ts:120:47
});

it('sets CLAWHUB_REGISTRY with HTTPS for bare zh locale', async () => {
mockGetSetting.mockResolvedValueOnce('zh');

const { ClawHubService } = await import('@electron/gateway/clawhub');
const service = new ClawHubService();

await service.search({ query: 'test' });

expect(spawnEnvCapture).toBeDefined();
expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBe('https://mirror-cn.clawhub.com');

Check failure on line 132 in tests/unit/clawhub-registry.test.ts

View workflow job for this annotation

GitHub Actions / check

tests/unit/clawhub-registry.test.ts > ClawHub China mirror registry > sets CLAWHUB_REGISTRY with HTTPS for bare zh locale

AssertionError: expected undefined to be 'https://mirror-cn.clawhub.com' // Object.is equality - Expected: "https://mirror-cn.clawhub.com" + Received: undefined ❯ tests/unit/clawhub-registry.test.ts:132:47
});

it('does NOT set CLAWHUB_REGISTRY for en locale', async () => {
mockGetSetting.mockResolvedValueOnce('en');

const { ClawHubService } = await import('@electron/gateway/clawhub');
const service = new ClawHubService();

await service.search({ query: 'test' });

expect(spawnEnvCapture).toBeDefined();
expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBeUndefined();
});

it('does NOT set CLAWHUB_REGISTRY for ja locale', async () => {
mockGetSetting.mockResolvedValueOnce('ja');

const { ClawHubService } = await import('@electron/gateway/clawhub');
const service = new ClawHubService();

await service.search({ query: 'test' });

expect(spawnEnvCapture).toBeDefined();
expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBeUndefined();
});

it('does NOT set CLAWHUB_REGISTRY when language is undefined', async () => {
mockGetSetting.mockResolvedValueOnce(undefined);

const { ClawHubService } = await import('@electron/gateway/clawhub');
const service = new ClawHubService();

await service.search({ query: 'test' });

expect(spawnEnvCapture).toBeDefined();
expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBeUndefined();
});

it('uses HTTPS, not HTTP, for the registry URL', async () => {
mockGetSetting.mockResolvedValueOnce('zh-CN');

const { ClawHubService } = await import('@electron/gateway/clawhub');
const service = new ClawHubService();

await service.search({ query: 'test' });

expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toMatch(/^https:\/\//);

Check failure on line 179 in tests/unit/clawhub-registry.test.ts

View workflow job for this annotation

GitHub Actions / check

tests/unit/clawhub-registry.test.ts > ClawHub China mirror registry > uses HTTPS, not HTTP, for the registry URL

TypeError: .toMatch() expects to receive a string, but got undefined ❯ tests/unit/clawhub-registry.test.ts:179:47
expect(spawnEnvCapture!.CLAWHUB_REGISTRY).not.toMatch(/^http:\/\//);
});
});
Loading