diff --git a/src/main/extensions/extension-host.ts b/src/main/extensions/extension-host.ts index 5c8345b7..49b457b7 100644 --- a/src/main/extensions/extension-host.ts +++ b/src/main/extensions/extension-host.ts @@ -1,5 +1,14 @@ import { join } from "path"; -import { existsSync, readdirSync, statSync, readFileSync, mkdirSync, rmSync, cpSync } from "fs"; +import { + existsSync, + readdirSync, + statSync, + readFileSync, + mkdirSync, + rmSync, + cpSync, + realpathSync, +} from "fs"; import type { ExtensionManifest, ExtensionModule, @@ -982,6 +991,21 @@ export class ExtensionHost { this.extensions.delete(extensionId); this.bundledModules.delete(extensionId); + // Clear Node.js require cache so reinstalling loads the new module from disk. + // We use createRequire to access the cache since the main process is ESM-bundled. + // Cache keys use realpath (symlinks resolved), so we must resolve ext.path too. + if (ext.path && existsSync(ext.path)) { + const { createRequire } = await import("module"); + const cache = createRequire(join(ext.path, "x")).cache; + const resolvedPath = realpathSync(ext.path); + const extPathPrefix = resolvedPath.endsWith("/") ? resolvedPath : resolvedPath + "/"; + for (const cached of Object.keys(cache)) { + if (cached.startsWith(extPathPrefix)) { + delete cache[cached]; + } + } + } + // Remove files if (ext.path && existsSync(ext.path)) { rmSync(ext.path, { recursive: true, force: true }); diff --git a/tests/problematic/ext-reinstall-e2e.spec.ts b/tests/problematic/ext-reinstall-e2e.spec.ts new file mode 100644 index 00000000..2c06db82 --- /dev/null +++ b/tests/problematic/ext-reinstall-e2e.spec.ts @@ -0,0 +1,136 @@ +import { test, expect, _electron as electron, Page, ElectronApplication } from "@playwright/test"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { execFileSync } from "child_process"; +import { fileURLToPath } from "url"; + +const __dirname = join(fileURLToPath(import.meta.url), ".."); + +function createTestExtensionZip(dir: string, version: string): string { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "mail-ext-test-reinstall", + version, + mailExtension: { + id: "test-reinstall", + displayName: "Test Reinstall v" + version, + description: "Test extension version " + version, + builtIn: false, + version, + activationEvents: ["onEmail"], + }, + }), + ); + writeFileSync( + join(dir, "dist", "main.js"), + [ + '"use strict";', + "module.exports = {", + ' VERSION: "' + version + '",', + " activate: function() {},", + " deactivate: function() {},", + "};", + ].join("\n"), + ); + const zipPath = join(dir, "ext-v" + version + ".zip"); + execFileSync("zip", ["-r", zipPath, "package.json", "dist/"], { cwd: dir }); + return zipPath; +} + +test.describe("Extension reinstall E2E", () => { + test.describe.configure({ mode: "serial" }); + let electronApp: ElectronApplication; + let page: Page; + const testDir = join(tmpdir(), "exo-ext-e2e-" + Date.now()); + let zipV1: string; + let zipV2: string; + + test.beforeAll(async () => { + mkdirSync(join(testDir, "v1"), { recursive: true }); + mkdirSync(join(testDir, "v2"), { recursive: true }); + zipV1 = createTestExtensionZip(join(testDir, "v1"), "1.0.0"); + zipV2 = createTestExtensionZip(join(testDir, "v2"), "2.0.0"); + + electronApp = await electron.launch({ + args: [join(__dirname, "../../out/main/index.js")], + env: { + ...process.env, + NODE_ENV: "test", + EXO_DEMO_MODE: "true", + }, + }); + page = await electronApp.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + await page.waitForSelector("text=Exo", { timeout: 15000 }); + }); + + test.afterAll(async () => { + try { + await page.evaluate(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).electronAPI.extensions.uninstall("test-reinstall"), + ); + } catch { + /* may not be installed */ + } + if (electronApp) { + const pid = electronApp.process().pid; + try { + if (pid) process.kill(pid, "SIGTERM"); + } catch { /* already exited */ } + await new Promise((r) => setTimeout(r, 2000)); + try { + if (pid) process.kill(pid, "SIGKILL"); + } catch { /* already exited */ } + } + rmSync(testDir, { recursive: true, force: true }); + }); + + test("install v1, then v2 over it, verify v2 loads", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const r1: any = await page.evaluate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (z) => (window as any).electronAPI.extensions.install(z), + zipV1, + ); + expect(r1.success).toBe(true); + expect(r1.data.id).toBe("test-reinstall"); + expect(r1.data.version).toBe("1.0.0"); + expect(r1.data.isActive).toBe(true); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const list1: any = await page.evaluate(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).electronAPI.extensions.listInstalled(), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ext1 = list1.data.find((e: any) => e.id === "test-reinstall"); + expect(ext1).toBeTruthy(); + expect(ext1.version).toBe("1.0.0"); + + // Install v2 over v1 (no restart) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const r2: any = await page.evaluate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (z) => (window as any).electronAPI.extensions.install(z), + zipV2, + ); + expect(r2.success).toBe(true); + expect(r2.data.id).toBe("test-reinstall"); + expect(r2.data.version).toBe("2.0.0"); + expect(r2.data.isActive).toBe(true); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const list2: any = await page.evaluate(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).electronAPI.extensions.listInstalled(), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ext2 = list2.data.find((e: any) => e.id === "test-reinstall"); + expect(ext2).toBeTruthy(); + expect(ext2.version).toBe("2.0.0"); + }); +}); diff --git a/tests/unit/extension-reinstall.spec.ts b/tests/unit/extension-reinstall.spec.ts new file mode 100644 index 00000000..ff4133c4 --- /dev/null +++ b/tests/unit/extension-reinstall.spec.ts @@ -0,0 +1,99 @@ +/** + * Test that the require.cache invalidation pattern works correctly. + * + * Verifies the fix: when uninstalling an extension, Node.js require.cache + * entries for that extension's modules are cleared so reinstalling loads + * the new module from disk instead of the stale cached one. + * + * This test exercises the exact same require/cache-clear pattern used in + * ExtensionHost without needing Electron or SQLite dependencies. + */ +import { test, expect } from "@playwright/test"; +import { mkdirSync, writeFileSync, rmSync, realpathSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { createRequire } from "module"; + +function writeExtensionModule(dir: string, version: string): void { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync( + join(dir, "dist", "main.js"), + `"use strict"; +module.exports = { VERSION: "${version}" }; +`, + ); +} + +/** Clear all require.cache entries under a directory (same pattern as ExtensionHost) */ +function clearRequireCache(dir: string): void { + const cache = createRequire(join(dir, "x")).cache; + // Cache keys use realpath (symlinks resolved, e.g. /private/var on macOS) + const resolvedDir = realpathSync(dir); + const prefix = resolvedDir + "/"; + for (const cached of Object.keys(cache)) { + if (cached.startsWith(prefix)) { + delete cache[cached]; + } + } +} + +test.describe("Extension require.cache invalidation", () => { + test.describe.configure({ mode: "serial" }); + + test("without cache clear, require returns stale module after file replacement", () => { + const testDir = join(tmpdir(), `exo-ext-cache-test-noclear-${Date.now()}`); + const extDir = join(testDir, "test-ext"); + mkdirSync(extDir, { recursive: true }); + + try { + const mainJsPath = join(extDir, "dist", "main.js"); + const extRequire = createRequire(mainJsPath); + + // Write and load v1 + writeExtensionModule(extDir, "1.0.0"); + const v1 = extRequire(mainJsPath); + expect(v1.VERSION).toBe("1.0.0"); + + // Overwrite with v2 on disk + writeExtensionModule(extDir, "2.0.0"); + + // Without clearing cache, require returns stale v1 + const stillV1 = extRequire(mainJsPath); + expect(stillV1.VERSION).toBe("1.0.0"); // BUG: stale! + } finally { + clearRequireCache(extDir); + rmSync(testDir, { recursive: true, force: true }); + } + }); + + test("with cache clear, require loads fresh module from disk", () => { + const testDir = join(tmpdir(), `exo-ext-cache-test-clear-${Date.now()}`); + const extDir = join(testDir, "test-ext"); + mkdirSync(extDir, { recursive: true }); + + try { + const mainJsPath = join(extDir, "dist", "main.js"); + + // Write and load v1 (same pattern as ExtensionHost.loadInstalledExtension) + writeExtensionModule(extDir, "1.0.0"); + const extRequireV1 = createRequire(mainJsPath); + const v1 = extRequireV1(mainJsPath); + expect(v1.VERSION).toBe("1.0.0"); + + // Simulate uninstall: clear cache using the same pattern as + // ExtensionHost.uninstallExtension() + clearRequireCache(extDir); + + // Overwrite with v2 on disk (simulates new extension files being placed) + writeExtensionModule(extDir, "2.0.0"); + + // Simulate reinstall: load again (same pattern as ExtensionHost) + const extRequireV2 = createRequire(mainJsPath); + const v2 = extRequireV2(mainJsPath); + expect(v2.VERSION).toBe("2.0.0"); // FIXED: fresh! + } finally { + clearRequireCache(extDir); + rmSync(testDir, { recursive: true, force: true }); + } + }); +});