Files
ccs/tests/unit/instance-manager-mcp-sync.test.ts
T
walkerandClaude Opus 4.6 eb8149e8fa feat(browser): 接入 CCS browser MCP 与最小交互闭环
新增 browser MCP 安装、Chrome 复用与运行时接线,
实现 navigate、click、type、take_screenshot 四个 phase-1 工具并补齐相关单测。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:19:16 +08:00

566 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { InstanceManager } from '../../src/management/instance-manager';
import SharedManager from '../../src/management/shared-manager';
describe('InstanceManager MCP sync', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
const claudeDir = () => path.join(tempRoot, '.claude');
const marketplacePath = (configDir: string, name = 'claude-code-plugins') =>
path.join(configDir, 'plugins', 'marketplaces', name);
const readJson = (filePath: string) =>
JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void {
fs.mkdirSync(marketplacePath(configDir, name), { recursive: true });
}
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation,
},
},
null,
2
),
'utf8'
);
}
function writeMarketplaceRegistryWithMetadata(
registryPath: string,
installLocation: string,
metadata: Record<string, unknown>
): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation,
...metadata,
},
},
null,
2
),
'utf8'
);
}
function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void {
const parsed = readJson(registryPath) as Record<string, { installLocation?: string }>;
expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation);
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
spyOn(os, 'homedir').mockReturnValue(tempRoot);
process.env.HOME = tempRoot;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
afterEach(() => {
mock.restore();
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('merges global MCP servers and preserves instance-specific overrides', () => {
fs.writeFileSync(
path.join(tempRoot, '.claude.json'),
JSON.stringify(
{
mcpServers: {
globalOnly: { command: 'global-cmd' },
shared: { command: 'global-shared' },
},
},
null,
2
),
'utf8'
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(
path.join(instancePath, '.claude.json'),
JSON.stringify(
{
mcpServers: {
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
},
otherKey: 'keep-me',
},
null,
2
),
'utf8'
);
const synced = manager.syncMcpServers(instancePath);
expect(synced).toBe(true);
const instanceContent = readJson(path.join(instancePath, '.claude.json')) as {
otherKey: string;
mcpServers: Record<string, { command: string }>;
};
expect(instanceContent.otherKey).toBe('keep-me');
expect(instanceContent.mcpServers).toEqual({
globalOnly: { command: 'global-cmd' },
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
});
});
it('repairs managed ccs-websearch and ccs-browser entries from global config while preserving other instance MCP overrides', () => {
fs.writeFileSync(
path.join(tempRoot, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'ccs-websearch': {
type: 'stdio',
command: 'node',
args: ['/global/server.cjs'],
env: {},
},
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/global/browser-server.cjs'],
env: {},
},
shared: { command: 'global-shared' },
},
},
null,
2
),
'utf8'
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(
path.join(instancePath, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'ccs-websearch': {
type: 'stdio',
command: 'node',
args: ['/old/server.cjs'],
env: {},
},
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/old/browser-server.cjs'],
env: {},
},
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
},
},
null,
2
),
'utf8'
);
expect(manager.syncMcpServers(instancePath)).toBe(true);
const instanceContent = readJson(path.join(instancePath, '.claude.json')) as {
mcpServers: Record<string, unknown>;
};
expect(instanceContent.mcpServers).toEqual({
'ccs-websearch': {
type: 'stdio',
command: 'node',
args: ['/global/server.cjs'],
env: {},
},
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/global/browser-server.cjs'],
env: {},
},
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
});
});
it('preserves existing instance .claude.json permissions when syncing managed MCP servers', () => {
fs.writeFileSync(
path.join(tempRoot, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/global/browser.cjs'],
env: {},
},
},
},
null,
2
),
'utf8'
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
const instanceClaudeJson = path.join(instancePath, '.claude.json');
fs.writeFileSync(instanceClaudeJson, JSON.stringify({ existing: true }, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o640,
});
fs.chmodSync(instanceClaudeJson, 0o640);
expect(manager.syncMcpServers(instancePath)).toBe(true);
expect(fs.statSync(instanceClaudeJson).mode & 0o777).toBe(0o640);
});
it('logs warning when global MCP sync fails', () => {
fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8');
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
const synced = manager.syncMcpServers(instancePath);
expect(synced).toBe(false);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped');
});
it('does not list lock housekeeping as an instance', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false);
const manager = new InstanceManager();
await manager.ensureInstance('work', { mode: 'isolated' });
expect(manager.listInstances()).toEqual(['work']);
});
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
const linkSharedSpy = spyOn(
SharedManager.prototype,
'linkSharedDirectories'
).mockImplementation(() => {});
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('sandbox');
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
ensureMarketplacePayload(claudeDir());
writeMarketplaceRegistry(
globalRegistryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
expect(linkSharedSpy).not.toHaveBeenCalled();
expect(fs.existsSync(instancePath)).toBe(true);
expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir()));
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
false
);
expect(syncMcpSpy).not.toHaveBeenCalled();
});
it('detaches existing shared layout when an instance is reopened as bare', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
syncMcpSpy.mockClear();
await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true });
expect(fs.existsSync(path.join(instancePath, 'settings.json'))).toBe(false);
expect(fs.existsSync(path.join(instancePath, 'commands'))).toBe(false);
expect(fs.existsSync(path.join(instancePath, 'skills'))).toBe(false);
expect(fs.existsSync(path.join(instancePath, 'agents'))).toBe(false);
expect(fs.existsSync(path.join(instancePath, 'plugins'))).toBe(false);
expect(syncMcpSpy).not.toHaveBeenCalled();
});
it('restores the shared layout when a bare-reopened instance is switched back to non-bare', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
ensureMarketplacePayload(claudeDir());
writeMarketplaceRegistry(globalRegistryPath, marketplacePath(claudeDir()));
const manager = new InstanceManager();
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true });
syncMcpSpy.mockClear();
await manager.ensureInstance('work', { mode: 'isolated' });
expect(fs.lstatSync(path.join(instancePath, 'settings.json')).isSymbolicLink()).toBe(true);
expectMarketplaceLocation(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
marketplacePath(instancePath)
);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
it('preserves genuine bare-local content when re-ensuring a bare instance', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('sandbox');
fs.mkdirSync(path.join(instancePath, 'plugins', 'marketplaces', 'custom-market'), {
recursive: true,
});
fs.mkdirSync(path.join(instancePath, 'commands'), { recursive: true });
fs.writeFileSync(
path.join(instancePath, 'settings.json'),
JSON.stringify({ local: true }, null, 2),
'utf8'
);
fs.writeFileSync(path.join(instancePath, 'commands', 'local.md'), '# local', 'utf8');
writeMarketplaceRegistry(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
marketplacePath(instancePath, 'custom-market')
);
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
expect(readJson(path.join(instancePath, 'settings.json'))).toEqual({ local: true });
expect(fs.readFileSync(path.join(instancePath, 'commands', 'local.md'), 'utf8')).toBe(
'# local'
);
expectMarketplaceLocation(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
marketplacePath(instancePath, 'custom-market')
);
expect(syncMcpSpy).not.toHaveBeenCalled();
});
it('rewrites existing non-bare instance marketplace metadata to the instance-local plugin dir', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
ensureMarketplacePayload(claudeDir());
writeMarketplaceRegistry(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
);
await manager.ensureInstance('work', { mode: 'isolated' });
expectMarketplaceLocation(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
marketplacePath(instancePath)
);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
it('writes new non-bare instance marketplace metadata without clobbering the global copy', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
ensureMarketplacePayload(claudeDir());
writeMarketplaceRegistry(
globalRegistryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
const manager = new InstanceManager();
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir()));
expectMarketplaceLocation(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
marketplacePath(instancePath)
);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
it('reconciles marketplace metadata across isolated instances without losing refresh fields', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const manager = new InstanceManager();
ensureMarketplacePayload(claudeDir());
const workPath = await manager.ensureInstance('work', { mode: 'isolated' });
const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json');
writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), {
label: 'Official marketplace',
refreshToken: 'refresh-token',
metadata: {
source: 'refresh-flow',
lastSyncedAt: '2026-03-18T00:00:00Z',
},
});
const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' });
const workRegistry = readJson(workRegistryPath) as Record<
string,
{
installLocation?: string;
label?: string;
refreshToken?: string;
metadata?: Record<string, unknown>;
}
>;
const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
string,
{
installLocation?: string;
label?: string;
refreshToken?: string;
metadata?: Record<string, unknown>;
}
>;
expect(workRegistry['claude-code-plugins']).toMatchObject({
installLocation: marketplacePath(workPath),
label: 'Official marketplace',
refreshToken: 'refresh-token',
metadata: {
source: 'refresh-flow',
lastSyncedAt: '2026-03-18T00:00:00Z',
},
});
expect(personalRegistry['claude-code-plugins']).toMatchObject({
installLocation: marketplacePath(personalPath),
label: 'Official marketplace',
refreshToken: 'refresh-token',
metadata: {
source: 'refresh-flow',
lastSyncedAt: '2026-03-18T00:00:00Z',
},
});
});
it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const manager = new InstanceManager();
const legacyPath = manager.getInstancePath('legacy');
const sharedPluginsPath = path.join(tempRoot, '.ccs', 'shared', 'plugins');
fs.mkdirSync(sharedPluginsPath, { recursive: true });
fs.mkdirSync(legacyPath, { recursive: true });
fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir');
ensureMarketplacePayload(claudeDir());
writeMarketplaceRegistryWithMetadata(
path.join(claudeDir(), 'plugins', 'known_marketplaces.json'),
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'),
{
label: 'Legacy marketplace',
refreshToken: 'legacy-refresh-token',
}
);
await manager.ensureInstance('legacy', { mode: 'isolated' });
expect(fs.lstatSync(path.join(legacyPath, 'plugins')).isSymbolicLink()).toBe(false);
expectMarketplaceLocation(
path.join(legacyPath, 'plugins', 'known_marketplaces.json'),
marketplacePath(legacyPath)
);
const legacyRegistry = readJson(
path.join(legacyPath, 'plugins', 'known_marketplaces.json')
) as Record<
string,
{ installLocation?: string; label?: string; refreshToken?: string }
>;
expect(legacyRegistry['claude-code-plugins']).toMatchObject({
installLocation: marketplacePath(legacyPath),
label: 'Legacy marketplace',
refreshToken: 'legacy-refresh-token',
});
});
});