[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
359
application/state/useExternalMcpToggleState.test.ts
Normal file
359
application/state/useExternalMcpToggleState.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user