From 3cbd69bc7f30885a5ca5afcbf9fae2d069fdd74c Mon Sep 17 00:00:00 2001 From: Dan Nyanko Date: Mon, 24 Aug 2026 08:15:08 -0400 Subject: [PATCH 1/4] fix(tests): polyfill localStorage in jsdom test setup jsdom does not reliably expose localStorage across runtimes (e.g. Node 26's experimental built-in shadows it), causing projectsPersistence/projectsSlice tests to fail with 'localStorage is undefined'. Add a minimal in-memory shim guarded by typeof check so it only applies when absent, keeping behavior identical on environments where jsdom already provides it. --- src/test/setup.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/test/setup.ts b/src/test/setup.ts index e9083a3..b366e2a 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,3 +1,43 @@ +// jsdom does not reliably expose `localStorage` across environments (e.g. on +// Node 26 its experimental built-in `localStorage` shadows the jsdom one, so +// `localStorage` is undefined in tests). Provide a minimal in-memory shim so +// tests that rely on `localStorage` run consistently regardless of the runtime. +if (typeof globalThis.localStorage === 'undefined') { + class MemoryStorage { + private store = new Map(); + + get length(): number { + return this.store.size; + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + + getItem(key: string): string | null { + return this.store.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } + + removeItem(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } + } + + const storage = new MemoryStorage(); + globalThis.localStorage = storage as unknown as Storage; + if (typeof window !== 'undefined') { + window.localStorage = storage as unknown as Storage; + } +} + beforeEach(() => { if (typeof localStorage !== 'undefined') { localStorage.clear(); From 76b52ae2cb5727357ea66df03715261f7614351b Mon Sep 17 00:00:00 2001 From: Dan Nyanko Date: Mon, 24 Aug 2026 07:42:41 -0400 Subject: [PATCH 2/4] feat: discover Firebase Emulator from FIRESTORE_EMULATOR_HOST env vars The existing scan only reads the Emulator Hub locator file, which is absent when the emulator is started without the Hub (e.g. firebase emulators:start --only firestore). Add discovery via FIRESTORE_EMULATOR_HOST, FIREBASE_AUTH_EMULATOR_HOST and FIREBASE_STORAGE_EMULATOR_HOST, merged with the hub scan and de-duplicated by Firestore host:port. --- electron/controllers/emulatorController.js | 101 ++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/electron/controllers/emulatorController.js b/electron/controllers/emulatorController.js index ed8be3d..ab0f89f 100644 --- a/electron/controllers/emulatorController.js +++ b/electron/controllers/emulatorController.js @@ -24,6 +24,78 @@ function readJsonSafely(filePath) { return null; } +/** + * Normalizes a host that is not directly reachable from a client. + * Emulators bind to 0.0.0.0 (IPv4) or [::] (IPv6) by default and report those + * addresses back via the environment. Browsers (and some runtimes) cannot + * connect to 0.0.0.0 / [::] as a destination, which breaks discovery on macOS + * in particular. Map those wildcard addresses to the loopback interface. + */ +function normalizeHost(host) { + if (!host) return host; + if (host === '0.0.0.0') return '127.0.0.1'; + if (host === '[::]' || host === '::' || host === '::1' || host === '[::1]') return '127.0.0.1'; + return host; +} + +/** + * Best-effort project ID guess for emulators discovered via environment + * variables (which do not carry a project ID). Reads .firebaserc by walking up + * from the current directory, then falls back to Firebase's conventional + * emulator placeholder. + */ +function readProjectIdGuess() { + let dir = process.cwd(); + for (let i = 0; i < 12; i++) { + const rc = readJsonSafely(path.join(dir, '.firebaserc')); + if (rc && rc.projects) { + const id = rc.projects.default || Object.keys(rc.projects)[0]; + if (id) return id; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return 'demo-project'; +} + +/** + * Parses a host:port string, normalizing the host. Returns null if malformed. + */ +function parseHostPort(value) { + if (!value) return null; + const idx = value.lastIndexOf(':'); + if (idx === -1) return null; + const host = normalizeHost(value.slice(0, idx)) || '127.0.0.1'; + const port = parseInt(value.slice(idx + 1), 10); + if (!Number.isFinite(port)) return null; + return { host, port }; +} + +/** + * Discovers emulators from standard Firebase Emulator environment variables + * (FIRESTORE_EMULATOR_HOST, FIREBASE_AUTH_EMULATOR_HOST, + * FIREBASE_STORAGE_EMULATOR_HOST). These are exported by the Emulator Suite and + * are an authoritative signal of a running emulator. + */ +function scanEnv() { + const firestore = parseHostPort(process.env.FIRESTORE_EMULATOR_HOST); + if (!firestore) return null; + + const services = { firestore }; + const auth = parseHostPort(process.env.FIREBASE_AUTH_EMULATOR_HOST); + if (auth) services.auth = auth; + const storage = parseHostPort(process.env.FIREBASE_STORAGE_EMULATOR_HOST); + if (storage) services.storage = storage; + + return { + projectId: readProjectIdGuess(), + host: firestore.host, + port: firestore.port, + services, + }; +} + /** * Scans the OS temp directory for running emulator hub files. * The hub locator file (hub-.json) only contains version, origins, and pid. @@ -85,6 +157,30 @@ async function scanHubFiles() { return runningEmulators; } +/** + * Merges all discovery strategies (hub locator files and environment + * variables), de-duplicating by Firestore host:port so a single running + * emulator is not reported multiple times. + */ +async function scanRunningEmulators() { + const results = []; + const seen = new Set(); + const add = (emulator) => { + if (!emulator) return; + const key = `${emulator.host}:${emulator.port}`; + if (seen.has(key)) return; + seen.add(key); + results.push(emulator); + }; + + const fromFiles = await scanHubFiles(); + fromFiles.forEach(add); + + add(scanEnv()); + + return results; +} + /** * Scans the Firebase CLI configstore to map project IDs to local paths */ @@ -116,10 +212,10 @@ function scanConfigstore() { * Registers all Emulator IPC handlers */ function registerHandlers() { - // Scans for running emulators via hub files + // Scans for running emulators via hub files and environment variables ipcMain.handle('emulators:scanHub', async () => { try { - const emulators = await scanHubFiles(); + const emulators = await scanRunningEmulators(); return { success: true, emulators }; } catch (err) { return { success: false, error: err.message }; @@ -139,4 +235,5 @@ function registerHandlers() { module.exports = { registerHandlers, + scanRunningEmulators, }; From 8abbccc6184b73882c730fb2d7f80c73f6e61519 Mon Sep 17 00:00:00 2001 From: Dan Nyanko Date: Mon, 24 Aug 2026 12:10:08 -0400 Subject: [PATCH 3/4] feat(emulator): detect project id from FIRESTORE_EMULATOR_PROJECT/GCLOUD_PROJECT readProjectIdGuess previously only consulted .firebaserc and fell back to the generic 'demo-project'. When connecting to a gcloud Firestore emulator (started with --project ), the discovered project id now honors FIRESTORE_EMULATOR_PROJECT or GCLOUD_PROJECT so it matches the emulator's actual project and returns data. --- electron/controllers/emulatorController.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/electron/controllers/emulatorController.js b/electron/controllers/emulatorController.js index ab0f89f..099fc36 100644 --- a/electron/controllers/emulatorController.js +++ b/electron/controllers/emulatorController.js @@ -45,6 +45,13 @@ function normalizeHost(host) { * emulator placeholder. */ function readProjectIdGuess() { + // Honor the project id the emulator was started with. The gcloud Firestore + // emulator (and the Firebase Emulator Suite) advertise it via these vars; + // without this, discovery falls back to the generic `demo-project` and the + // connection's project id won't match the emulator's, returning no data. + const envProject = process.env.FIRESTORE_EMULATOR_PROJECT || process.env.GCLOUD_PROJECT; + if (envProject) return envProject; + let dir = process.cwd(); for (let i = 0; i < 12; i++) { const rc = readJsonSafely(path.join(dir, '.firebaserc')); From 5dac2f89fdb8cfcd834b3240ef8b03716214c142 Mon Sep 17 00:00:00 2001 From: Dan Nyanko Date: Mon, 24 Aug 2026 12:37:30 -0400 Subject: [PATCH 4/4] feat(emulator): normalize localhost to 127.0.0.1 for emulator hosts gRPC clients resolve 'localhost' to IPv6 ::1 and can hang/fail to connect to the emulator. Rewrite 'localhost' (and wildcard bind addresses) to 127.0.0.1 in normalizeHost so discovered emulator hosts connect reliably over IPv4. --- electron/controllers/emulatorController.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/electron/controllers/emulatorController.js b/electron/controllers/emulatorController.js index 099fc36..d4c0b5a 100644 --- a/electron/controllers/emulatorController.js +++ b/electron/controllers/emulatorController.js @@ -33,6 +33,9 @@ function readJsonSafely(filePath) { */ function normalizeHost(host) { if (!host) return host; + // `localhost` resolves to IPv6 `::1` for gRPC clients and can hang/fail to + // connect; route it to the IPv4 loopback, which emulators reliably serve. + if (host === 'localhost') return '127.0.0.1'; if (host === '0.0.0.0') return '127.0.0.1'; if (host === '[::]' || host === '::' || host === '::1' || host === '[::1]') return '127.0.0.1'; return host;