Files
NetMesh/application/state/useExternalMcpToggleState.test.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

360 lines
15 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
createExternalMcpStartupSyncPlan,
getExternalMcpEnableGenerationForTests,
getExternalMcpStartupReadyWaiterCountForTests,
isExternalMcpStartupReady,
markExternalMcpStartupReady,
normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode,
normalizeSessionIdleTimeoutMinutes,
readExternalMcpFocusOnHostOpen,
readExternalMcpSilentSessions,
readExternalMcpStoredEnabled,
resetExternalMcpStartupReadyForTests,
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpSilentSessions,
} from './useExternalMcpToggleState.ts';
function installMemoryLocalStorage() {
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, 'dispatchEvent');
const backing = new Map<string, string>();
const storage: Storage = {
get length() { return backing.size; },
clear() { backing.clear(); },
getItem(key: string) { return backing.get(key) ?? null; },
key(index: number) { return Array.from(backing.keys())[index] ?? null; },
removeItem(key: string) { backing.delete(key); },
setItem(key: string, value: string) { backing.set(key, value); },
};
Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true });
Object.defineProperty(globalThis, 'dispatchEvent', { value: () => true, configurable: true });
return () => {
if (previousLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
} else {
Reflect.deleteProperty(globalThis, 'localStorage');
}
if (previousDispatchEvent) {
Object.defineProperty(globalThis, 'dispatchEvent', previousDispatchEvent);
} else {
Reflect.deleteProperty(globalThis, 'dispatchEvent');
}
};
}
describe('useExternalMcpToggleState helpers', () => {
it('normalizes mode and idle timeout', () => {
assert.equal(normalizeExternalMcpMode('persistent'), 'persistent');
assert.equal(normalizeExternalMcpMode('other'), 'temporary');
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
});
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: false, mode: 'persistent' }), false);
});
it('startup sync plan clears temporary stored enabled', () => {
const plan = createExternalMcpStartupSyncPlan({
enabled: true,
mode: 'temporary',
idleTimeoutMinutes: 15,
sessionIdleTimeoutMinutes: 30,
});
assert.equal(plan.runtimeEnabled, false);
assert.equal(plan.storedEnabled, false);
assert.equal(plan.shouldPersistStoredEnabled, true);
assert.equal(plan.config.idleTimeoutMinutes, 15);
assert.equal(plan.config.sessionIdleTimeoutMinutes, 30);
});
it('startup sync plan keeps persistent enabled', () => {
const plan = createExternalMcpStartupSyncPlan({
enabled: true,
mode: 'persistent',
idleTimeoutMinutes: 20,
sessionIdleTimeoutMinutes: 45,
});
assert.equal(plan.runtimeEnabled, true);
assert.equal(plan.storedEnabled, true);
assert.equal(plan.shouldPersistStoredEnabled, false);
});
it('focus-on-host-open defaults to true and round-trips through storage', () => {
const restore = installMemoryLocalStorage();
try {
assert.equal(readExternalMcpFocusOnHostOpen(), true);
writeExternalMcpFocusOnHostOpen(false);
assert.equal(readExternalMcpFocusOnHostOpen(), false);
writeExternalMcpFocusOnHostOpen(true);
assert.equal(readExternalMcpFocusOnHostOpen(), true);
} finally {
restore();
}
});
it('polls runtime status on a short interval for the top-bar switch', () => {
assert.equal(EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS, 3000);
});
it('silent-sessions defaults to false and round-trips through storage', () => {
const restore = installMemoryLocalStorage();
try {
assert.equal(readExternalMcpSilentSessions(), false);
writeExternalMcpSilentSessions(true);
assert.equal(readExternalMcpSilentSessions(), true);
writeExternalMcpSilentSessions(false);
assert.equal(readExternalMcpSilentSessions(), false);
} finally {
restore();
}
});
});
describe('useExternalMcpToggleState runtime poll wiring', () => {
it('uses the shared poll interval constant', async () => {
const source = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('./useExternalMcpToggleState.ts', import.meta.url), 'utf8'),
);
assert.match(source, /EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000/);
assert.match(source, /setInterval\([\s\S]*EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS\)/);
assert.doesNotMatch(source, /setInterval\([\s\S]*,\s*30000\)/);
});
});
describe('useExternalMcpToggleState startup ready gate', () => {
it('blocks main-window runtime poll consumers until startup reconcile marks ready', async () => {
resetExternalMcpStartupReadyForTests();
assert.equal(isExternalMcpStartupReady(), false);
assert.equal(shouldWaitForExternalMcpStartupReady(''), true);
assert.equal(shouldWaitForExternalMcpStartupReady('#/settings'), false);
assert.equal(shouldWaitForExternalMcpStartupReady('#/tray'), false);
assert.equal(shouldWaitForExternalMcpStartupReady('#/session-window'), false);
let resolved = false;
const pending = waitForExternalMcpStartupReady('').then(() => {
resolved = true;
});
// Single-flight: repeated waits share one waiter.
const pending2 = waitForExternalMcpStartupReady('');
await Promise.resolve();
assert.equal(resolved, false);
assert.equal(getExternalMcpStartupReadyWaiterCountForTests(), 1);
markExternalMcpStartupReady();
await pending;
await pending2;
assert.equal(isExternalMcpStartupReady(), true);
assert.equal(resolved, true);
assert.equal(getExternalMcpStartupReadyWaiterCountForTests(), 0);
await waitForExternalMcpStartupReady('');
});
it('does not block settings/tray consumers on the App-only gate', async () => {
resetExternalMcpStartupReadyForTests();
await waitForExternalMcpStartupReady('#/settings');
await waitForExternalMcpStartupReady('#/tray');
assert.equal(isExternalMcpStartupReady(), false);
assert.equal(getExternalMcpStartupReadyWaiterCountForTests(), 0);
});
it('wires App startup reconcile to release the gate after enable settles', async () => {
const appSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8'),
);
const hookSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('./useExternalMcpToggleState.ts', import.meta.url), 'utf8'),
);
assert.match(appSource, /await syncExternalMcpStartupStateOnce\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /markExternalMcpStartupReady\(\)/);
assert.ok(
appSource.indexOf('await syncExternalMcpStartupStateOnce(netcattyBridge.get())')
< appSource.indexOf('markExternalMcpStartupReady()'),
'startup ready must be marked only after await syncExternalMcpStartupStateOnce',
);
assert.match(hookSource, /export async function syncExternalMcpStartupState/);
assert.match(hookSource, /await Promise\.resolve\(bridge\?\.externalMcpSetEnabled\?\.\(plan\.runtimeEnabled\)\)/);
assert.match(hookSource, /shouldWaitForExternalMcpStartupReady/);
assert.match(hookSource, /!status\.enabled && !status\.error/);
assert.match(hookSource, /if \(isPeerSessionWindow \|\| !enabled\) return;/);
});
it('restores saved permission before enabling persistent External MCP', async () => {
const restore = installMemoryLocalStorage();
try {
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, 'auto');
const calls: string[] = [];
await syncExternalMcpStartupState({
aiMcpSetPermissionMode: async (mode) => {
calls.push(`permission:${mode}`);
return { ok: true };
},
externalMcpSetConfig: async () => {
calls.push('config');
return { ok: true };
},
externalMcpSetEnabled: async (enabled) => {
calls.push(`enabled:${enabled}`);
return { ok: true, enabled };
},
});
assert.deepEqual(calls, ['permission:auto', 'config', 'enabled:true']);
} finally {
restore();
}
});
it('re-reads storage after config await so concurrent top-bar toggles win', async () => {
const restore = installMemoryLocalStorage();
try {
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
let enabledCalls: boolean[] = [];
const plan = await syncExternalMcpStartupState({
externalMcpSetConfig: async () => {
// Simulate a user turning the top-bar switch off while config sync is in flight.
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'false');
return { ok: true };
},
externalMcpSetEnabled: async (enabled) => {
enabledCalls.push(enabled);
return { ok: true, enabled };
},
});
assert.equal(plan.runtimeEnabled, false);
assert.deepEqual(enabledCalls, [false]);
assert.equal(readExternalMcpStoredEnabled(), false);
} finally {
restore();
}
});
it('keeps stored switch when startup enable reports disabled with error', async () => {
const restore = installMemoryLocalStorage();
try {
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
assert.equal(readExternalMcpStoredEnabled(), true);
const plan = await syncExternalMcpStartupState({
externalMcpSetConfig: async () => ({ ok: true }),
externalMcpSetEnabled: async () => ({ ok: true, enabled: false, state: 'error', error: 'boom' }),
});
assert.equal(plan.runtimeEnabled, true);
assert.equal(plan.storedEnabled, true);
assert.equal(readExternalMcpStoredEnabled(), true);
} finally {
restore();
}
});
});
describe('syncExternalMcpStartupStateOnce single-flight', () => {
it('reaches the bridge once when the startup effect is invoked twice', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
resetExternalMcpStartupSyncOnceForTests();
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
const configCalls: unknown[] = [];
const enabledCalls: boolean[] = [];
const bridge = {
externalMcpSetConfig: async (config: unknown) => {
configCalls.push(config);
return { ok: true };
},
externalMcpSetEnabled: async (enabled: boolean) => {
enabledCalls.push(enabled);
return { ok: true, enabled };
},
};
// Mount, cleanup, mount again: both effect runs share one reconcile.
const first = syncExternalMcpStartupStateOnce(bridge);
const second = syncExternalMcpStartupStateOnce(bridge);
assert.equal(first, second);
const [firstPlan, secondPlan] = await Promise.all([first, second]);
assert.equal(firstPlan, secondPlan);
assert.equal(firstPlan.runtimeEnabled, true);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
// A later remount awaits the settled promise instead of re-sending IPC.
await syncExternalMcpStartupStateOnce(bridge);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
} finally {
resetExternalMcpStartupSyncOnceForTests();
restore();
}
});
});
describe('syncExternalMcpStartupState generation guard', () => {
it('skips stale startup enable after concurrent top-bar toggle generation bump', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
const enabledCalls: boolean[] = [];
const plan = await syncExternalMcpStartupState({
externalMcpSetConfig: async () => {
// Simulate a top-bar toggle landing during config await.
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'false');
bumpExternalMcpEnableGenerationForTests();
return { ok: true };
},
externalMcpSetEnabled: async (enabled) => {
enabledCalls.push(enabled);
return { ok: true, enabled };
},
});
assert.equal(plan.runtimeEnabled, false);
// Generation changed during config await, so stale startup enable is skipped.
assert.deepEqual(enabledCalls, []);
assert.equal(readExternalMcpStoredEnabled(), false);
assert.ok(getExternalMcpEnableGenerationForTests() > 0);
} finally {
restore();
}
});
});