feat: multi-wallet architecture with localStorage state, EIP-712 verification, cross-tab WS leadership
- wallets.js: WalletManager state management with chain validation - verifier.js: WalletVerifier EIP-712 signing via window.ethereum - stream.js: WalletStreamManager with BroadcastChannel leader election, backoff + jitter - AGENTS.md: updated with coding guidelines
This commit is contained in:
122
test/test_tabs.js
Normal file
122
test/test_tabs.js
Normal file
@ -0,0 +1,122 @@
|
||||
import { WalletManager } from '../wallets.js';
|
||||
import { WalletStreamManager } from '../stream.js';
|
||||
|
||||
// --- 1. Infrastructure Mocks ---
|
||||
const mockStorage = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => mockStorage[key] || null,
|
||||
setItem: (key, value) => { mockStorage[key] = String(value); },
|
||||
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }
|
||||
};
|
||||
|
||||
// Accelerate timers so leader election (200ms) and backoff happen instantly
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (callback, delay) => originalSetTimeout(callback, 1);
|
||||
|
||||
// Standard WebSocket Mock
|
||||
class MockWebSocket {
|
||||
constructor(url) {
|
||||
this.readyState = 1; // Immediately OPEN
|
||||
originalSetTimeout(() => { if (this.onopen) this.onopen(); }, 1);
|
||||
}
|
||||
send(data) { globalLastSentPayload = JSON.parse(data); }
|
||||
close() { if (this.onclose) this.onclose({ code: 1000 }); }
|
||||
}
|
||||
globalThis.WebSocket = MockWebSocket;
|
||||
|
||||
// --- 2. The Cross-Tab Broadcast Router Mock ---
|
||||
const activeChannels = new Set();
|
||||
class MockBroadcastChannel {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
activeChannels.add(this);
|
||||
}
|
||||
postMessage(data) {
|
||||
// Distribute the message to all OTHER open channel instances simulation offline cross-tab delivery
|
||||
for (const channel of activeChannels) {
|
||||
if (channel !== this && channel.onmessage) {
|
||||
channel.onmessage({ data });
|
||||
}
|
||||
}
|
||||
}
|
||||
close() { activeChannels.delete(this); }
|
||||
}
|
||||
globalThis.BroadcastChannel = MockBroadcastChannel;
|
||||
|
||||
// Global tracking variables for assertions
|
||||
let globalLastSentPayload = null;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(`❌ FAIL: ${message}`);
|
||||
console.log(`✅ PASS: ${message}`);
|
||||
}
|
||||
|
||||
// --- 3. The Multi-Tab Simulation Scenario ---
|
||||
async function runMultiTabTest() {
|
||||
console.log("🚀 Starting Multi-Tab Synchronization & Leader Election Test Suite...\n");
|
||||
localStorage.clear();
|
||||
|
||||
// Seed initial wallet configuration
|
||||
const wm = new WalletManager();
|
||||
wm.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Shared Wallet");
|
||||
|
||||
// --- STEP 1: Boot First Tab ---
|
||||
console.log("▶️ Initializing Tab 1...");
|
||||
const tab1 = new WalletStreamManager(wm);
|
||||
tab1.connect();
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 15)); // Await leader election window
|
||||
assert(tab1.isLeader === true, "Tab 1 detected no existing leaders and claimed leadership role.");
|
||||
assert(tab1.ws !== null, "Tab 1 successfully initialized a physical WebSocket network connection.");
|
||||
|
||||
// --- STEP 2: Boot Second Tab ---
|
||||
console.log("\n▶️ Initializing Tab 2 (Simulating a new browser window/tab)...");
|
||||
const tab2 = new WalletStreamManager(wm);
|
||||
tab2.connect();
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 15));
|
||||
assert(tab2.isLeader === false, "Tab 2 discovered active Leader and assigned itself as a Follower.");
|
||||
assert(tab2.ws === undefined || tab2.ws === null, "Tab 2 kept its local WebSocket offline to save system resources.");
|
||||
|
||||
// --- STEP 3: Verify Event Cross-Broadcasting Flow ---
|
||||
let tab2ReceivedData = false;
|
||||
tab2.on('balance_update', (msg) => {
|
||||
if (msg.balance === "9999") tab2ReceivedData = true;
|
||||
});
|
||||
|
||||
// Simulate server pushing an update specifically to Tab 1's socket connection
|
||||
tab1.ws.onmessage({
|
||||
data: JSON.stringify({ type: "balance_update", balance: "9999" })
|
||||
});
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 5));
|
||||
assert(tab2ReceivedData === true, "Tab 1 received WebSocket data and pushed it locally across the BroadcastChannel to Tab 2.");
|
||||
|
||||
// --- STEP 4: Proxy Subscription Modification Check ---
|
||||
console.log("\n▶️ Simulating Follower (Tab 2) subscribing to a new asset dynamically...");
|
||||
const newAsset = { address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", chain: "bitcoin" };
|
||||
|
||||
globalLastSentPayload = null;
|
||||
tab2.subscribeToNewWallet(newAsset);
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 5));
|
||||
assert(globalLastSentPayload !== null, "Tab 2's subscription was successfully intercepted and routed to the channel.");
|
||||
assert(globalLastSentPayload.action === "subscribe_wallet", "Tab 1 successfully executed the proxy network subscription on behalf of Tab 2.");
|
||||
|
||||
// --- STEP 5: Graceful Failover Handover (Leader Death Scenario) ---
|
||||
console.log("\n🕵️♂️ Simulating user abruptly closing Tab 1 (Leader)...");
|
||||
|
||||
// Simulate browser exit/unload routine for Tab 1
|
||||
tab1.disconnect();
|
||||
// Dispatch the exit event if your module binds directly to window event listeners
|
||||
if (tab1._handleExit) tab1._handleExit();
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 15)); // Await emergency election frame
|
||||
|
||||
assert(tab2.isLeader === true, "Tab 2 caught the LEADER_DEATH signal and successfully promoted itself to Leader.");
|
||||
assert(tab2.ws !== null, "Tab 2 gracefully opened a brand new physical WebSocket connection to pick up the stream.");
|
||||
|
||||
console.log("\n🎉 Massive Success! Cross-tab synchronization, proxy forwarding, and leader failovers function perfectly.");
|
||||
}
|
||||
|
||||
runMultiTabTest().catch(console.error);
|
||||
Reference in New Issue
Block a user