Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/main/extensions/extension-host.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
136 changes: 136 additions & 0 deletions tests/problematic/ext-reinstall-e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
99 changes: 99 additions & 0 deletions tests/unit/extension-reinstall.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading