hot-reload.integration.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. /**
  2. * Integration test for hot configuration reload
  3. *
  4. * This test verifies the complete hot reload workflow:
  5. * 1. Load initial configuration
  6. * 2. Start watching for changes
  7. * 3. Modify configuration file
  8. * 4. Verify automatic reload within 100ms
  9. * 5. Verify account updates are reflected
  10. *
  11. * Tests MUST FAIL initially until implementation is provided.
  12. */
  13. import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
  14. import * as fs from 'fs/promises';
  15. import * as path from 'path';
  16. import * as os from 'os';
  17. // Import types (this import will fail until types are implemented)
  18. import type {
  19. ICredentialManager,
  20. Account,
  21. Platform,
  22. ConfigFile
  23. } from '@/specs/001-credential-manager/contracts/credential-manager';
  24. describe('Hot Configuration Reload Integration Tests', () => {
  25. let credentialManager: ICredentialManager;
  26. let tempDir: string;
  27. let configPath: string;
  28. beforeEach(async () => {
  29. // This will fail until CredentialManager is implemented
  30. const { CredentialManager } = await import('@/core/credential-manager/CredentialManager');
  31. credentialManager = new CredentialManager();
  32. // Create temporary directory for test files
  33. tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hot-reload-test-'));
  34. configPath = path.join(tempDir, 'credentials.json');
  35. });
  36. afterEach(async () => {
  37. // Clean up
  38. credentialManager.stopWatching();
  39. // Remove temporary directory
  40. try {
  41. await fs.rm(tempDir, { recursive: true, force: true });
  42. } catch (error) {
  43. // Ignore cleanup errors
  44. }
  45. });
  46. describe('Initial Load and Setup', () => {
  47. test('should load initial configuration successfully', async () => {
  48. // Arrange
  49. const initialConfig: ConfigFile = {
  50. version: "1.0",
  51. accounts: [
  52. {
  53. id: "initial-pacifica-account",
  54. platform: Platform.PACIFICA,
  55. name: "Initial Pacifica Account",
  56. credentials: {
  57. type: "ed25519",
  58. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  59. }
  60. }
  61. ]
  62. };
  63. await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2));
  64. // Act
  65. const loadResult = await credentialManager.loadConfig(configPath);
  66. // Assert
  67. expect(loadResult.success).toBe(true);
  68. expect(loadResult.accounts).toHaveLength(1);
  69. expect(loadResult.accounts[0].id).toBe("initial-pacifica-account");
  70. // Verify account is accessible via getAccount
  71. const account = credentialManager.getAccount("initial-pacifica-account");
  72. expect(account).not.toBeNull();
  73. expect(account!.platform).toBe(Platform.PACIFICA);
  74. });
  75. test('should start file watching without errors', async () => {
  76. // Arrange
  77. const initialConfig: ConfigFile = {
  78. version: "1.0",
  79. accounts: []
  80. };
  81. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  82. await credentialManager.loadConfig(configPath);
  83. const changeCallback = jest.fn();
  84. // Act & Assert - should not throw
  85. expect(() => {
  86. credentialManager.watchConfig(configPath, changeCallback);
  87. }).not.toThrow();
  88. });
  89. });
  90. describe('Hot Reload Performance', () => {
  91. test('should complete hot reload within 100ms performance requirement', async () => {
  92. // Arrange
  93. const initialConfig: ConfigFile = {
  94. version: "1.0",
  95. accounts: [
  96. {
  97. id: "performance-test-account",
  98. platform: Platform.PACIFICA,
  99. name: "Performance Test Account",
  100. credentials: {
  101. type: "ed25519",
  102. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  103. }
  104. }
  105. ]
  106. };
  107. await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2));
  108. await credentialManager.loadConfig(configPath);
  109. // Setup reload detection
  110. const reloadPromise = new Promise<{accounts: Account[], reloadTime: number}>((resolve) => {
  111. const startTime = Date.now();
  112. credentialManager.watchConfig(configPath, (accounts) => {
  113. const reloadTime = Date.now() - startTime;
  114. resolve({ accounts, reloadTime });
  115. });
  116. });
  117. // Wait for watcher to initialize
  118. await new Promise(resolve => setTimeout(resolve, 100));
  119. // Act - modify configuration
  120. const modifiedConfig: ConfigFile = {
  121. version: "1.0",
  122. accounts: [
  123. {
  124. id: "performance-test-account",
  125. platform: Platform.PACIFICA,
  126. name: "Modified Performance Test Account",
  127. credentials: {
  128. type: "ed25519",
  129. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  130. }
  131. },
  132. {
  133. id: "new-account",
  134. platform: Platform.BINANCE,
  135. name: "New Account",
  136. credentials: {
  137. type: "hmac",
  138. apiKey: "test-api-key",
  139. secretKey: "test-secret-key"
  140. }
  141. }
  142. ]
  143. };
  144. const modifyStartTime = Date.now();
  145. await fs.writeFile(configPath, JSON.stringify(modifiedConfig, null, 2));
  146. // Assert - wait for reload with timeout
  147. const { accounts, reloadTime } = await Promise.race([
  148. reloadPromise,
  149. new Promise<never>((_, reject) =>
  150. setTimeout(() => reject(new Error('Hot reload timeout - exceeded 5 seconds')), 5000)
  151. )
  152. ]);
  153. // Performance requirement: < 100ms
  154. expect(reloadTime).toBeLessThan(100);
  155. expect(accounts).toHaveLength(2);
  156. // Verify accounts are updated in credential manager
  157. const updatedAccount = credentialManager.getAccount("performance-test-account");
  158. expect(updatedAccount!.name).toBe("Modified Performance Test Account");
  159. const newAccount = credentialManager.getAccount("new-account");
  160. expect(newAccount).not.toBeNull();
  161. expect(newAccount!.platform).toBe(Platform.BINANCE);
  162. });
  163. test('should handle rapid successive file changes efficiently', async () => {
  164. // Arrange
  165. const baseConfig: ConfigFile = {
  166. version: "1.0",
  167. accounts: []
  168. };
  169. await fs.writeFile(configPath, JSON.stringify(baseConfig));
  170. await credentialManager.loadConfig(configPath);
  171. let reloadCount = 0;
  172. const reloadTimes: number[] = [];
  173. credentialManager.watchConfig(configPath, (accounts) => {
  174. reloadCount++;
  175. reloadTimes.push(Date.now());
  176. });
  177. // Wait for watcher to initialize
  178. await new Promise(resolve => setTimeout(resolve, 100));
  179. // Act - make rapid changes
  180. const startTime = Date.now();
  181. for (let i = 0; i < 5; i++) {
  182. const config = {
  183. ...baseConfig,
  184. accounts: [
  185. {
  186. id: `rapid-account-${i}`,
  187. platform: Platform.PACIFICA,
  188. name: `Rapid Account ${i}`,
  189. credentials: {
  190. type: "ed25519",
  191. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  192. }
  193. }
  194. ]
  195. };
  196. await fs.writeFile(configPath, JSON.stringify(config));
  197. await new Promise(resolve => setTimeout(resolve, 20)); // Small delay between changes
  198. }
  199. // Wait for debouncing and final reload
  200. await new Promise(resolve => setTimeout(resolve, 500));
  201. // Assert - should be debounced (fewer reloads than changes)
  202. expect(reloadCount).toBeLessThan(5);
  203. expect(reloadCount).toBeGreaterThan(0);
  204. // Final state should be correct
  205. const finalAccount = credentialManager.getAccount("rapid-account-4");
  206. expect(finalAccount).not.toBeNull();
  207. });
  208. });
  209. describe('Account Updates and State Management', () => {
  210. test('should add new accounts on configuration update', async () => {
  211. // Arrange
  212. const initialConfig: ConfigFile = {
  213. version: "1.0",
  214. accounts: [
  215. {
  216. id: "existing-account",
  217. platform: Platform.PACIFICA,
  218. name: "Existing Account",
  219. credentials: {
  220. type: "ed25519",
  221. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  222. }
  223. }
  224. ]
  225. };
  226. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  227. await credentialManager.loadConfig(configPath);
  228. const updatePromise = new Promise<Account[]>((resolve) => {
  229. credentialManager.watchConfig(configPath, resolve);
  230. });
  231. // Wait for watcher
  232. await new Promise(resolve => setTimeout(resolve, 100));
  233. // Act - add new account
  234. const updatedConfig: ConfigFile = {
  235. version: "1.0",
  236. accounts: [
  237. ...initialConfig.accounts,
  238. {
  239. id: "new-aster-account",
  240. platform: Platform.ASTER,
  241. name: "New Aster Account",
  242. credentials: {
  243. type: "eip191",
  244. privateKey: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
  245. }
  246. }
  247. ]
  248. };
  249. await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2));
  250. // Assert
  251. const accounts = await updatePromise;
  252. expect(accounts).toHaveLength(2);
  253. // Verify both accounts are accessible
  254. expect(credentialManager.getAccount("existing-account")).not.toBeNull();
  255. expect(credentialManager.getAccount("new-aster-account")).not.toBeNull();
  256. expect(credentialManager.getAccount("new-aster-account")!.platform).toBe(Platform.ASTER);
  257. });
  258. test('should remove accounts when deleted from configuration', async () => {
  259. // Arrange
  260. const initialConfig: ConfigFile = {
  261. version: "1.0",
  262. accounts: [
  263. {
  264. id: "account-to-keep",
  265. platform: Platform.PACIFICA,
  266. name: "Account to Keep",
  267. credentials: {
  268. type: "ed25519",
  269. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  270. }
  271. },
  272. {
  273. id: "account-to-remove",
  274. platform: Platform.BINANCE,
  275. name: "Account to Remove",
  276. credentials: {
  277. type: "hmac",
  278. apiKey: "test-api-key",
  279. secretKey: "test-secret-key"
  280. }
  281. }
  282. ]
  283. };
  284. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  285. await credentialManager.loadConfig(configPath);
  286. // Verify both accounts exist initially
  287. expect(credentialManager.getAccount("account-to-keep")).not.toBeNull();
  288. expect(credentialManager.getAccount("account-to-remove")).not.toBeNull();
  289. const updatePromise = new Promise<Account[]>((resolve) => {
  290. credentialManager.watchConfig(configPath, resolve);
  291. });
  292. // Wait for watcher
  293. await new Promise(resolve => setTimeout(resolve, 100));
  294. // Act - remove one account
  295. const updatedConfig: ConfigFile = {
  296. version: "1.0",
  297. accounts: [
  298. {
  299. id: "account-to-keep",
  300. platform: Platform.PACIFICA,
  301. name: "Account to Keep",
  302. credentials: {
  303. type: "ed25519",
  304. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  305. }
  306. }
  307. ]
  308. };
  309. await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2));
  310. // Assert
  311. const accounts = await updatePromise;
  312. expect(accounts).toHaveLength(1);
  313. // Verify correct account state
  314. expect(credentialManager.getAccount("account-to-keep")).not.toBeNull();
  315. expect(credentialManager.getAccount("account-to-remove")).toBeNull();
  316. });
  317. test('should update existing account credentials', async () => {
  318. // Arrange
  319. const initialConfig: ConfigFile = {
  320. version: "1.0",
  321. accounts: [
  322. {
  323. id: "updatable-account",
  324. platform: Platform.PACIFICA,
  325. name: "Original Name",
  326. credentials: {
  327. type: "ed25519",
  328. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  329. }
  330. }
  331. ]
  332. };
  333. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  334. await credentialManager.loadConfig(configPath);
  335. const updatePromise = new Promise<Account[]>((resolve) => {
  336. credentialManager.watchConfig(configPath, resolve);
  337. });
  338. // Wait for watcher
  339. await new Promise(resolve => setTimeout(resolve, 100));
  340. // Act - update account details
  341. const updatedConfig: ConfigFile = {
  342. version: "1.0",
  343. accounts: [
  344. {
  345. id: "updatable-account",
  346. platform: Platform.PACIFICA,
  347. name: "Updated Name",
  348. credentials: {
  349. type: "ed25519",
  350. privateKey: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
  351. }
  352. }
  353. ]
  354. };
  355. await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2));
  356. // Assert
  357. const accounts = await updatePromise;
  358. expect(accounts).toHaveLength(1);
  359. const updatedAccount = credentialManager.getAccount("updatable-account");
  360. expect(updatedAccount).not.toBeNull();
  361. expect(updatedAccount!.name).toBe("Updated Name");
  362. expect(updatedAccount!.credentials.privateKey).toBe("fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210");
  363. });
  364. });
  365. describe('Error Handling During Hot Reload', () => {
  366. test('should handle malformed configuration during reload', async () => {
  367. // Arrange
  368. const initialConfig: ConfigFile = {
  369. version: "1.0",
  370. accounts: [
  371. {
  372. id: "stable-account",
  373. platform: Platform.PACIFICA,
  374. name: "Stable Account",
  375. credentials: {
  376. type: "ed25519",
  377. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  378. }
  379. }
  380. ]
  381. };
  382. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  383. await credentialManager.loadConfig(configPath);
  384. // Verify initial state
  385. expect(credentialManager.getAccount("stable-account")).not.toBeNull();
  386. let errorOccurred = false;
  387. credentialManager.watchConfig(configPath, (accounts) => {
  388. // This callback should not be called for malformed config
  389. errorOccurred = true;
  390. });
  391. // Wait for watcher
  392. await new Promise(resolve => setTimeout(resolve, 100));
  393. // Act - write malformed configuration
  394. const malformedConfig = '{ "version": "1.0", "accounts": [ invalid json }';
  395. await fs.writeFile(configPath, malformedConfig);
  396. // Wait for file watcher to process
  397. await new Promise(resolve => setTimeout(resolve, 500));
  398. // Assert - original state should be preserved
  399. expect(errorOccurred).toBe(false);
  400. expect(credentialManager.getAccount("stable-account")).not.toBeNull();
  401. });
  402. test('should handle file deletion during watching', async () => {
  403. // Arrange
  404. const initialConfig: ConfigFile = {
  405. version: "1.0",
  406. accounts: [
  407. {
  408. id: "persistent-account",
  409. platform: Platform.PACIFICA,
  410. name: "Persistent Account",
  411. credentials: {
  412. type: "ed25519",
  413. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  414. }
  415. }
  416. ]
  417. };
  418. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  419. await credentialManager.loadConfig(configPath);
  420. credentialManager.watchConfig(configPath, (accounts) => {
  421. // Should handle file deletion gracefully
  422. });
  423. // Wait for watcher
  424. await new Promise(resolve => setTimeout(resolve, 100));
  425. // Act - delete configuration file
  426. await fs.unlink(configPath);
  427. // Wait for file watcher to process
  428. await new Promise(resolve => setTimeout(resolve, 500));
  429. // Assert - should not crash, previous state may be preserved
  430. expect(() => {
  431. credentialManager.getAccount("persistent-account");
  432. }).not.toThrow();
  433. });
  434. test('should handle temporary file operations (atomic writes)', async () => {
  435. // Arrange
  436. const initialConfig: ConfigFile = {
  437. version: "1.0",
  438. accounts: []
  439. };
  440. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  441. await credentialManager.loadConfig(configPath);
  442. let updateCount = 0;
  443. credentialManager.watchConfig(configPath, (accounts) => {
  444. updateCount++;
  445. });
  446. // Wait for watcher
  447. await new Promise(resolve => setTimeout(resolve, 100));
  448. // Act - simulate atomic write operations (common with editors)
  449. const tempPath = configPath + '.tmp';
  450. const finalConfig: ConfigFile = {
  451. version: "1.0",
  452. accounts: [
  453. {
  454. id: "atomic-account",
  455. platform: Platform.PACIFICA,
  456. name: "Atomic Account",
  457. credentials: {
  458. type: "ed25519",
  459. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  460. }
  461. }
  462. ]
  463. };
  464. // Write to temp file then rename (atomic operation)
  465. await fs.writeFile(tempPath, JSON.stringify(finalConfig, null, 2));
  466. await fs.rename(tempPath, configPath);
  467. // Wait for file watcher to process
  468. await new Promise(resolve => setTimeout(resolve, 500));
  469. // Assert - should detect the final state
  470. expect(updateCount).toBeGreaterThan(0);
  471. expect(credentialManager.getAccount("atomic-account")).not.toBeNull();
  472. });
  473. });
  474. describe('Cleanup and Resource Management', () => {
  475. test('should stop watching when requested', async () => {
  476. // Arrange
  477. const initialConfig: ConfigFile = {
  478. version: "1.0",
  479. accounts: []
  480. };
  481. await fs.writeFile(configPath, JSON.stringify(initialConfig));
  482. await credentialManager.loadConfig(configPath);
  483. let callbackCount = 0;
  484. credentialManager.watchConfig(configPath, () => {
  485. callbackCount++;
  486. });
  487. // Wait for watcher
  488. await new Promise(resolve => setTimeout(resolve, 100));
  489. // Act - stop watching
  490. credentialManager.stopWatching();
  491. // Modify file after stopping
  492. const modifiedConfig: ConfigFile = {
  493. version: "1.0",
  494. accounts: [
  495. {
  496. id: "should-not-trigger",
  497. platform: Platform.PACIFICA,
  498. name: "Should Not Trigger",
  499. credentials: {
  500. type: "ed25519",
  501. privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  502. }
  503. }
  504. ]
  505. };
  506. await fs.writeFile(configPath, JSON.stringify(modifiedConfig));
  507. // Wait to ensure no callback is triggered
  508. await new Promise(resolve => setTimeout(resolve, 500));
  509. // Assert - callback should not have been called after stopping
  510. expect(callbackCount).toBe(0);
  511. });
  512. test('should handle multiple start/stop watch cycles', async () => {
  513. // Arrange
  514. const config: ConfigFile = {
  515. version: "1.0",
  516. accounts: []
  517. };
  518. await fs.writeFile(configPath, JSON.stringify(config));
  519. await credentialManager.loadConfig(configPath);
  520. // Act & Assert - multiple cycles should not cause issues
  521. for (let i = 0; i < 3; i++) {
  522. credentialManager.watchConfig(configPath, () => {});
  523. await new Promise(resolve => setTimeout(resolve, 50));
  524. credentialManager.stopWatching();
  525. await new Promise(resolve => setTimeout(resolve, 50));
  526. }
  527. // Should end in clean state
  528. expect(() => {
  529. credentialManager.stopWatching();
  530. }).not.toThrow();
  531. });
  532. });
  533. });