diff --git a/app/scripts/fetch_xulrunner b/app/scripts/fetch_xulrunner index 60cd2f90ec..ebfb43de85 100755 --- a/app/scripts/fetch_xulrunner +++ b/app/scripts/fetch_xulrunner @@ -460,6 +460,37 @@ function modify_omni { replace_line 'let \{ BrowserAddonUI \} = windowRoot.window;' '' $file replace_line 'await BrowserAddonUI.promptRemoveExtension' 'promptRemoveExtension' $file + file="chrome/toolkit/content/mozapps/extensions/components/addon-list.mjs" + # The Add-ons window can retain the old add-on wrapper after a replacement. + # Finalizing that wrapper's pending uninstall would remove the new copy by ID. + replace_line ' disconnectedCallback\(\) \{' ' async disconnectedCallback() {' $file + replace_line ' for \(const addon of this.pendingUninstallAddons\) \{' \ + ' for (const addon of [...this.pendingUninstallAddons]) {' $file + replace_line ' if \(isPending\(addon, "uninstall"\)\) \{' \ + ' let current; + try { + current = await AddonManager.getAddonByID(addon.id); + } catch (error) { + console.error(error); + return; + } + if (this.isConnected) { + return; + } + if (this.pendingUninstallAddons.has(addon) && current === addon && isPending(current, "uninstall")) {' $file + replace_line ' addon.uninstall\(\);' \ + ' addon.uninstall(); + this.pendingUninstallAddons.delete(addon);' $file + replace_line ' onInstalled\(addon\) \{' ' onInstalled(addon) { + if (!isPending(addon, "uninstall")) { + for (const pending of this.pendingUninstallAddons) { + if (pending.id === addon.id) { + this.pendingUninstallAddons.delete(pending); + this.removePendingUninstallBar(pending); + } + } + }' $file + # Customize empty-list message replace_line 'createEmptyListMessage\(\) {' 'createEmptyListMessage() { var p = document.createElement("p"); diff --git a/app/scripts/tests/addon-list-pending-uninstall.test.mjs b/app/scripts/tests/addon-list-pending-uninstall.test.mjs new file mode 100644 index 0000000000..5241afc889 --- /dev/null +++ b/app/scripts/tests/addon-list-pending-uninstall.test.mjs @@ -0,0 +1,133 @@ +// Run against the addon-list.mjs extracted from the patched Firefox omni.ja: +// node app/scripts/tests/addon-list-pending-uninstall.test.mjs +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const sourcePath = process.argv.at(-1); +if (!sourcePath?.endsWith('addon-list.mjs')) { + throw new Error('Pass the patched addon-list.mjs path as the last argument'); +} +const source = readFileSync(sourcePath, 'utf8') + .replace(/^import \{[\s\S]*?\} from "\.\.\/aboutaddons-utils\.mjs";\s*/mu, '') + .replace('export class AddonList', 'class AddonList') + .replace('customElements.define("addon-list", AddonList);', 'globalThis.AddonList = AddonList;'); + +function createList(currentById) { + const errors = []; + const context = { + console: { error: error => errors.push(error) }, + HTMLElement: class {}, + ChromeUtils: { + importESModule: () => ({ AddonManager: { + getAddonByID: id => typeof currentById === 'function' + ? currentById(id) : Promise.resolve(currentById.get(id)), + } }), + defineESModuleGetters: () => {}, + }, + isPending: addon => addon.pendingUninstall, + }; + runInNewContext(source, context, { filename: sourcePath }); + const list = new context.AddonList(); + list.removeListener = () => {}; + list.updateAddon = () => {}; + list.removePendingUninstallBar = () => {}; + list.errors = errors; + return list; +} + +test('a replacement clears the pending removal and survives list teardown', async () => { + let uninstalls = 0; + const old = { id: 'plugin@test', pendingUninstall: true, uninstall: () => uninstalls++ }; + const replacement = { id: old.id, pendingUninstall: false }; + const list = createList(new Map([[old.id, replacement]])); + list.pendingUninstallAddons.add(old); + list.onInstalled(replacement); + assert.equal(list.pendingUninstallAddons.size, 0); + await list.disconnectedCallback(); + assert.equal(uninstalls, 0); +}); + +test('list teardown checks the live add-on even before the install event arrives', async () => { + let uninstalls = 0; + const old = { id: 'plugin@test', pendingUninstall: true, uninstall: () => uninstalls++ }; + const replacement = { id: old.id, pendingUninstall: false }; + const list = createList(new Map([[old.id, replacement]])); + list.pendingUninstallAddons.add(old); + await list.disconnectedCallback(); + assert.equal(uninstalls, 0); +}); + +test('a genuinely pending removal still finalizes', async () => { + let uninstalls = 0; + const old = { id: 'plugin@test', pendingUninstall: true, uninstall: () => uninstalls++ }; + const list = createList(new Map([[old.id, old]])); + list.pendingUninstallAddons.add(old); + await list.disconnectedCallback(); + assert.equal(uninstalls, 1); +}); + +test('a reconnect during lookup leaves newly queued removals intact', async () => { + let resolveLookup; + let uninstalls = 0; + const old = { id: 'old@test', pendingUninstall: true, uninstall: () => uninstalls++ }; + const fresh = { id: 'fresh@test', pendingUninstall: true }; + const list = createList(() => new Promise(resolve => { resolveLookup = resolve; })); + list.pendingUninstallAddons.add(old); + const teardown = list.disconnectedCallback(); + assert.equal(list.pendingUninstallAddons.has(old), true); + list.isConnected = true; + list.pendingUninstallAddons.add(fresh); + resolveLookup(old); + await teardown; + assert.equal(uninstalls, 0); + assert.equal(list.pendingUninstallAddons.has(fresh), true); +}); + +test('install notification during lookup removes the stale entry before reconnect', async () => { + let resolveLookup; + let uninstalls = 0; + const old = { id: 'plugin@test', pendingUninstall: true, uninstall: () => uninstalls++ }; + const replacement = { id: old.id, pendingUninstall: false }; + const list = createList(() => new Promise(resolve => { resolveLookup = resolve; })); + list.pendingUninstallAddons.add(old); + const teardown = list.disconnectedCallback(); + list.onInstalled(replacement); + list.isConnected = true; + resolveLookup(replacement); + await teardown; + assert.equal(uninstalls, 0); + assert.equal(list.pendingUninstallAddons.has(old), false); +}); + +test('a reconnect mid-loop preserves the unprocessed pending removal', async () => { + let resolveSecond; + let firstUninstalls = 0; + let secondUninstalls = 0; + const first = { id: 'first@test', pendingUninstall: true, + uninstall: () => firstUninstalls++ }; + const second = { id: 'second@test', pendingUninstall: true, + uninstall: () => secondUninstalls++ }; + const list = createList(id => id === first.id ? Promise.resolve(first) + : new Promise(resolve => { resolveSecond = resolve; })); + list.pendingUninstallAddons.add(first); + list.pendingUninstallAddons.add(second); + const teardown = list.disconnectedCallback(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(firstUninstalls, 1); + list.isConnected = true; + resolveSecond(second); + await teardown; + assert.equal(secondUninstalls, 0); + assert.equal(list.pendingUninstallAddons.has(second), true); +}); + +test('a failed current-add-on lookup retains the pending removal', async () => { + const old = { id: 'plugin@test', pendingUninstall: true }; + const list = createList(() => Promise.reject(new Error('lookup failed'))); + list.pendingUninstallAddons.add(old); + await list.disconnectedCallback(); + assert.equal(list.pendingUninstallAddons.has(old), true); + assert.equal(list.errors.length, 1); +});