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
13 changes: 8 additions & 5 deletions apps/desktop/src/main/learn-host/__tests__/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1350,12 +1350,13 @@ describe('LearnController 状态机', () => {
it('Claude-only 本地 skill 也算已装:注入原文、diff 有基线、标记 personal', async () => {
const claudeDir = path.join('/', 'claude', 'skills', 'my-skill');
let diffOldDir: string | null | undefined;
const fetchHubSkill = vi.fn(async () => ({
name: 'my-skill',
description: 'upstream',
content: '# upstream skill',
}));
const h = makeHarness({
fetchHubSkill: async () => ({
name: 'my-skill',
description: 'upstream',
content: '# upstream skill',
}),
fetchHubSkill,
search: async () => ({ hits: [], sessions: {}, nextCursor: null, vectorUsed: false }) as never,
collectProfile: async () => ({ block: '', used: false }),
resolveInstalledSkillDirs: (name) => [path.join('/', 'installed', name), path.join('/', 'claude', 'skills', name)],
Expand All @@ -1369,6 +1370,8 @@ describe('LearnController 状态机', () => {
h.setScan(goodScan());
const { runId } = await h.controller.startLearn({ input: '', sourceKind: 'hub', hubSlug: 'my-skill' });
await h.waitForStatus(runId, 'distilling');
expect(fetchHubSkill).toHaveBeenCalledWith('my-skill', 'market');
expect(h.store.get(runId)?.hubCatalogScope).toBe('market');
expect(h.session.sent[0]).toContain('# local Claude skill');
expect(h.store.get(runId)!.usedSessionEvidence).toBe(true);

Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/main/learn-host/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export class LearnController {
...(dataOwnerId ? { dataOwnerId } : {}),
input,
...(req.hubSlug ? { hubSlug: req.hubSlug } : {}),
...(req.hubCatalogScope ? { hubCatalogScope: req.hubCatalogScope } : {}),
...(req.sourceKind === 'hub' ? { hubCatalogScope: req.hubCatalogScope ?? 'market' } : {}),
...(req.originSessionId ? { originSessionId: req.originSessionId } : {}),
usedSessionEvidence: false,
createdAt: this.now(),
Expand Down Expand Up @@ -397,7 +397,7 @@ export class LearnController {
let referenceFilesOmissions: Array<{ path: string; reason: string }> | undefined;
let evidenceQuery = run.input;
if (run.sourceKind === 'hub' && run.hubSlug && this.deps.fetchHubSkill) {
const hub = await this.deps.fetchHubSkill(run.hubSlug, run.hubCatalogScope);
const hub = await this.deps.fetchHubSkill(run.hubSlug, run.hubCatalogScope ?? 'market');
if (!hub) throw new LearnError('NOT_FOUND', `hub skill ${run.hubSlug} not found`);
// fetch 的网络 await 期间可能被 cancel(cleanup 已删 staging):此处不设门
// 的话 writeReferenceFiles 会把 _reference/ 整个重建成孤儿目录(自查)。
Expand Down Expand Up @@ -520,7 +520,7 @@ export class LearnController {

const cleanMessage =
run.sourceKind === 'hub'
? `/learn hub:${run.hubCatalogScope ? `${run.hubCatalogScope}:` : ''}${run.hubSlug}`
? `/learn hub:${run.hubCatalogScope ?? 'market'}:${run.hubSlug}`
: run.sourceKind === 'session'
? '/learn (distill current conversation)'
: `/learn ${run.input}`;
Expand Down Expand Up @@ -964,7 +964,7 @@ export class LearnController {
const provenance: LearnProvenance = {
method: 'learn',
sourceKind: run.sourceKind,
...(run.hubSlug ? { sourceRef: `${run.hubCatalogScope ? `${run.hubCatalogScope}:` : ''}${run.hubSlug}` } : {}),
...(run.hubSlug ? { sourceRef: `${run.hubCatalogScope ?? 'market'}:${run.hubSlug}` } : {}),
usedSessionEvidence: run.usedSessionEvidence,
personal: run.usedSessionEvidence, // 硬规则:含 session 证据 ⇒ personal,不可配置
learnedAt: Math.floor(this.now() / 1000),
Expand Down
94 changes: 71 additions & 23 deletions apps/desktop/src/main/skillhub/__tests__/autoSyncService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { net } from 'electron';
import { getCurrentUserId } from '../../authManager';
import type { StoredInstall } from '../registry/types';
import { registryService } from '../registry';
import { SkillhubAutoSyncService } from '../autoSyncService';
import { parseAutoSyncConfig, SkillhubAutoSyncService } from '../autoSyncService';
import type { install as installFn } from '../installService';

type InstallResult = Awaited<ReturnType<typeof installFn>>;
Expand Down Expand Up @@ -82,18 +82,20 @@ function installEntry(version: string, overrides: Partial<StoredInstall> = {}):
updatedAt: 1,
origin: 'installed',
autoSynced: true,
catalogScope: 'market',
catalogScopeMigrated: true,
...overrides,
};
}

function makeService(options: {
userId?: string | null;
configSkills?: Array<string | { name: string; version?: string; enabled?: boolean }>;
configSkills?: Array<string | { name: string; version?: string; enabled?: boolean; catalogScope?: 'market' | 'team' }>;
installs?: Array<{ skillName: string; installPath: string; entry: StoredInstall }>;
listAllInstallsImpl?: () => Promise<Array<{ skillName: string; installPath: string; entry: StoredInstall }>>;
fetchConfigImpl?: () => Promise<Array<{ name: string; version?: string; enabled?: boolean }>>;
syncResults?: Array<{ name: string; exists: boolean; latestVersion?: string }>;
syncResponses?: Array<{ success: boolean; results?: Array<{ name: string; exists: boolean; latestVersion?: string }>; error?: string }>;
fetchConfigImpl?: () => Promise<Array<{ name: string; version?: string; enabled?: boolean; catalogScope?: 'market' | 'team' }>>;
syncResults?: Array<{ name: string; exists: boolean; latestVersion?: string; catalogScope?: 'market' | 'team' }>;
syncResponses?: Array<{ success: boolean; results?: Array<{ name: string; exists: boolean; latestVersion?: string; catalogScope?: 'market' | 'team' }>; error?: string }>;
syncSuccess?: boolean;
installImpl?: typeof installFn;
installResults?: Array<SuccessfulInstallResult | FailedInstallResult>;
Expand Down Expand Up @@ -248,8 +250,8 @@ describe('SkillhubAutoSyncService', () => {

expect(listIgnoredSkills).toHaveBeenCalledTimes(2);
expect(recordCandidateSkills).toHaveBeenCalledWith('user-1', ['alpha'], { replace: true });
expect(syncMarket).toHaveBeenCalledWith({ slugs: ['alpha'] });
expect(install).toHaveBeenCalledWith({ name: 'alpha', autoSync: true }, expect.any(Function));
expect(syncMarket).toHaveBeenCalledWith({ skills: [{ slug: 'alpha', catalogScope: 'market' }] });
expect(install).toHaveBeenCalledWith({ name: 'alpha', catalogScope: 'market', autoSync: true }, expect.any(Function));
});

it('continues with non-ignored skills when only part of the config is ignored', async () => {
Expand All @@ -261,8 +263,8 @@ describe('SkillhubAutoSyncService', () => {

await service.runOnceAfterLogin();

expect(syncMarket).toHaveBeenCalledWith({ slugs: ['beta'] });
expect(install).toHaveBeenCalledWith({ name: 'beta', autoSync: true }, expect.any(Function));
expect(syncMarket).toHaveBeenCalledWith({ skills: [{ slug: 'beta', catalogScope: 'market' }] });
expect(install).toHaveBeenCalledWith({ name: 'beta', catalogScope: 'market', autoSync: true }, expect.any(Function));
});

it('records empty fallback candidates without replacing previous remote candidates when the config fetch fails', async () => {
Expand Down Expand Up @@ -310,7 +312,24 @@ describe('SkillhubAutoSyncService', () => {

await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith({ name: 'alpha', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenCalledWith({ name: 'alpha', catalogScope: 'market', autoSync: true }, expect.any(Function));
});

it('keeps the configured team catalog scope through sync and install', async () => {
const { service, syncMarket, install } = makeService({
configSkills: [{ name: 'alpha', catalogScope: 'team' }],
syncResults: [{ name: 'alpha', catalogScope: 'team', exists: true, latestVersion: '1.2.3' }],
});

await service.runOnceAfterLogin();

expect(syncMarket).toHaveBeenCalledWith({
skills: [{ slug: 'alpha', catalogScope: 'team' }],
});
expect(install).toHaveBeenCalledWith(
{ name: 'alpha', catalogScope: 'team', autoSync: true },
expect.any(Function),
);
});

it('continues auto-sync after deleting a corrupt pending cleanup store', async () => {
Expand All @@ -324,7 +343,7 @@ describe('SkillhubAutoSyncService', () => {

await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith({ name: 'alpha', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenCalledWith({ name: 'alpha', catalogScope: 'market', autoSync: true }, expect.any(Function));
expect(fs.existsSync(storePath)).toBe(false);
expect(loggerMocks.warn).toHaveBeenCalledWith(
'parse pending cancellation cleanup store failed',
Expand All @@ -342,7 +361,7 @@ describe('SkillhubAutoSyncService', () => {

await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith({ name: 'alpha', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenCalledWith({ name: 'alpha', catalogScope: 'market', autoSync: true }, expect.any(Function));
expect(loggerMocks.warn).toHaveBeenCalledWith(
'read pending cancellation cleanup store failed',
{ error: expect.any(String) },
Expand Down Expand Up @@ -477,7 +496,7 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith(
{ name: 'alpha', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: false },
{ name: 'alpha', catalogScope: 'market', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: false },
expect.any(Function),
);
});
Expand All @@ -500,7 +519,7 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith(
{ name: 'alpha', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: false },
{ name: 'alpha', catalogScope: 'market', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: false },
expect.any(Function),
);
} finally {
Expand All @@ -519,7 +538,7 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith(
{ name: 'alpha', autoSync: true, version: '1.1.0' },
{ name: 'alpha', catalogScope: 'market', autoSync: true, version: '1.1.0' },
expect.any(Function),
);
});
Expand Down Expand Up @@ -552,7 +571,7 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith(
{ name: 'alpha', autoSync: true, installPath, version: '1.1.0', force: true, skipBackup: false },
{ name: 'alpha', catalogScope: 'market', autoSync: true, installPath, version: '1.1.0', force: true, skipBackup: false },
expect.any(Function),
);
});
Expand Down Expand Up @@ -611,7 +630,7 @@ describe('SkillhubAutoSyncService', () => {

await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith({ name: 'alpha', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenCalledWith({ name: 'alpha', catalogScope: 'market', autoSync: true }, expect.any(Function));
expect(loggerMocks.warn).toHaveBeenCalledWith(
'user-owned global skill registry entry is missing on disk',
{ slug: 'alpha', origin: 'published', installPath },
Expand Down Expand Up @@ -681,7 +700,7 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith(
{ name: 'alpha', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: false },
{ name: 'alpha', catalogScope: 'market', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: false },
expect.any(Function),
);
});
Expand Down Expand Up @@ -716,7 +735,7 @@ describe('SkillhubAutoSyncService', () => {

await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith({ name: 'alpha', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenCalledWith({ name: 'alpha', catalogScope: 'market', autoSync: true }, expect.any(Function));
});

it('skips a whitelisted skill when the local version already matches SkillHub', async () => {
Expand Down Expand Up @@ -749,7 +768,7 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith(
{ name: 'alpha', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: true },
{ name: 'alpha', catalogScope: 'market', autoSync: true, installPath, version: '1.2.3', force: true, skipBackup: true },
expect.any(Function),
);
expect(loggerMocks.warn).toHaveBeenCalledWith(
Expand Down Expand Up @@ -804,8 +823,8 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledTimes(2);
expect(install).toHaveBeenNthCalledWith(1, { name: 'alpha', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenNthCalledWith(2, { name: 'beta', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenNthCalledWith(1, { name: 'alpha', catalogScope: 'market', autoSync: true }, expect.any(Function));
expect(install).toHaveBeenNthCalledWith(2, { name: 'beta', catalogScope: 'market', autoSync: true }, expect.any(Function));
expect(loggerMocks.warn).toHaveBeenCalledWith(
'auto install/update failed',
{ slug: 'alpha', version: '1.0.0', errorCode: 'INTERNAL', message: 'boom' },
Expand Down Expand Up @@ -1142,7 +1161,7 @@ describe('SkillhubAutoSyncService', () => {
await service.runOnceAfterLogin();

expect(install).toHaveBeenCalledWith(
{ name: 'alpha', autoSync: true, installPath, version: '2.0.0', force: true, skipBackup: false },
{ name: 'alpha', catalogScope: 'market', autoSync: true, installPath, version: '2.0.0', force: true, skipBackup: false },
expect.any(Function),
);
expect(cleanupInstall).toHaveBeenCalledWith({
Expand Down Expand Up @@ -1218,3 +1237,32 @@ describe('SkillhubAutoSyncService', () => {
expect(install).toHaveBeenCalledTimes(2);
});
});

describe('parseAutoSyncConfig', () => {
it('preserves explicit catalog scopes and defaults legacy string entries to market', () => {
expect(parseAutoSyncConfig({ skills: [
'public-skill',
{ slug: 'team-skill', catalogScope: 'team' },
{ name: 'scope-alias', scope: 'market' },
] })).toEqual([
{ name: 'public-skill', catalogScope: 'market' },
{ name: 'team-skill', catalogScope: 'team' },
{ name: 'scope-alias', catalogScope: 'market' },
]);
});

it('rejects entries with an invalid explicit catalog scope', () => {
expect(parseAutoSyncConfig({ skills: [
{ slug: 'wrong-scope', catalogScope: 'native' },
] })).toEqual([]);
});

it('uses the first catalog scope when config repeats a slug for the single global install slot', () => {
expect(parseAutoSyncConfig({ skills: [
{ slug: 'shared-name', catalogScope: 'team' },
{ slug: 'shared-name', catalogScope: 'market' },
] })).toEqual([
{ name: 'shared-name', catalogScope: 'team' },
]);
});
});
51 changes: 51 additions & 0 deletions apps/desktop/src/main/skillhub/__tests__/identityPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const state = vi.hoisted(() => ({ boundaryPending: false, user: null as unknown }));

vi.mock('../../appSessionState.js', () => ({
isAppSessionBoundaryPending: () => state.boundaryPending,
}));

vi.mock('../../authManager', () => ({
getAuthState: () => ({ user: state.user }),
}));

vi.mock('../../serverApiClient', () => ({
ServerApiError: class ServerApiError extends Error {
constructor(
public readonly code: string,
public readonly statusCode: number,
message: string,
) {
super(message);
}
},
}));

describe('SkillHub identity write policy', () => {
beforeEach(() => {
state.boundaryPending = false;
state.user = { membershipKind: 'personal' };
vi.resetModules();
});

it('fails closed while the application session owner boundary is pending', async () => {
state.boundaryPending = true;
const { assertSkillhubVisibilityAllowed, assertSkillhubWriteAllowed } = await import('../identityPolicy');

expect(() => assertSkillhubWriteAllowed()).toThrow(expect.objectContaining({
code: 'PRECONDITION_FAILED',
statusCode: 409,
}));
expect(() => assertSkillhubVisibilityAllowed('public')).toThrow(expect.objectContaining({
code: 'PRECONDITION_FAILED',
statusCode: 409,
}));
});

it('allows a stable signed-in personal session', async () => {
const { assertSkillhubWriteAllowed } = await import('../identityPolicy');

expect(() => assertSkillhubWriteAllowed()).not.toThrow();
});
});
10 changes: 5 additions & 5 deletions apps/desktop/src/main/skillhub/__tests__/marketService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,13 @@ describe('SkillhubMarketService', () => {
updateRegistryCatalogScope,
});

await service.setPublishedVisibility({ name: 'demo', visibility: 'shared' });
await service.setPublishedVisibility({ name: 'demo', visibility: 'private' });
await service.setPublishedVisibility({ name: 'demo', visibility: 'shared', previousCatalogScope: 'market' });
await service.setPublishedVisibility({ name: 'demo', visibility: 'private', previousCatalogScope: 'team' });
await service.setPublishedVisibility({ name: 'demo', visibility: 'public' });

expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(1, 'demo', 'team');
expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(2, 'demo', undefined);
expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(3, 'demo', undefined);
expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(1, 'demo', 'team', 'market');
expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(2, 'demo', undefined, 'team');
expect(updateRegistryCatalogScope).toHaveBeenNthCalledWith(3, 'demo', undefined, undefined);
});

it('maps categories and user departments into renderer result shapes', async () => {
Expand Down
Loading
Loading