diff --git a/eleventy.config.js b/eleventy.config.js index af3e61de..53753504 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -42,12 +42,12 @@ export default async function (eleventyConfig) { if (!fs.existsSync(manifestPath) || !fs.existsSync(mainPath)) continue; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); listings.push({ - id: manifest.id, + id: manifest.id + "__LOCAL", name: manifest.name, author: manifest.author, description: manifest.description, }); - const destDir = path.join("build/plugins-local", entry.name); + const destDir = path.join("build/plugins-local", manifest.id + "__LOCAL"); fs.mkdirSync(destDir, { recursive: true }); fs.copyFileSync(manifestPath, path.join(destDir, "manifest.json")); fs.copyFileSync(mainPath, path.join(destDir, "main.js")); diff --git a/package.json b/package.json index 4136e462..42a8bac4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.14.24", + "version": "0.14.25", "type": "module", "scripts": { "start": "rm -rf build && NODE_ENV=development eleventy --serve", diff --git a/src/js/plugins/pluginBridge.js b/src/js/plugins/pluginBridge.js index 354e8323..151dbb73 100644 --- a/src/js/plugins/pluginBridge.js +++ b/src/js/plugins/pluginBridge.js @@ -235,13 +235,14 @@ export class PluginBridge { return handler(pluginInstance, message); } + // Request: {id, version, repo?} async loadPlugins(pluginRequests) { const loadedPlugins = []; const erroredPlugins = []; await Promise.all( - pluginRequests.map(async ({ id, version }) => { + pluginRequests.map(async ({ id, version, repo }) => { try { - const plugin = await this.loadPlugin(id, version); + const plugin = await this.loadPlugin(id, version, repo); loadedPlugins.push(plugin); } catch (error) { erroredPlugins.push({ pluginId: id, version, error }); @@ -254,18 +255,18 @@ export class PluginBridge { }; } - async loadPlugin(pluginId, version) { + async loadPlugin(pluginId, version, repo) { if (this._loadedPlugins.has(pluginId)) return; let manifest; try { - manifest = await this._provider.getManifest(pluginId, version); + manifest = await this._provider.getManifest(pluginId, version, repo); } catch (error) { logger.warn(`failed to load "${pluginId}": invalid manifest`, error); throw new Error("Failed to load plugin manifest"); } let source; try { - source = await this._provider.getSource(pluginId, version); + source = await this._provider.getSource(pluginId, version, repo); } catch (error) { logger.error( `failed to load "${pluginId}": could not fetch main.js`, @@ -350,8 +351,8 @@ export class PluginBridge { this._loadedPlugins.delete(pluginId); } - async reloadPlugin(pluginId, version) { + async reloadPlugin(pluginId, version, repo) { this.unloadPlugin(pluginId); - return this.loadPlugin(pluginId, version); + return this.loadPlugin(pluginId, version, repo); } } diff --git a/src/js/plugins/pluginCache.js b/src/js/plugins/pluginCache.js index d49d9527..ed2150ab 100644 --- a/src/js/plugins/pluginCache.js +++ b/src/js/plugins/pluginCache.js @@ -4,20 +4,15 @@ const CACHE_NAME = "plugins-v1"; // URLs are versioned so new versions should fetch new entries export class PluginCache { - constructor({ cachesImpl, fetchImpl } = {}) { - this._caches = cachesImpl ?? window.caches; - this._fetch = fetchImpl ?? ((...args) => window.fetch(...args)); - } - async _getCache() { - return await this._caches.open(CACHE_NAME); + return await caches.open(CACHE_NAME); } async fetch(url) { const cache = await this._getCache(); let response = await cache.match(url); if (!response) { - response = await this._fetch(url, { redirect: "follow" }); + response = await fetch(url, { redirect: "follow" }); if (!response.ok) throw new Error(`HTTP ${response.status} ${url}`); await cache.put(url, response.clone()); } diff --git a/src/js/plugins/pluginPreferencesManager.js b/src/js/plugins/pluginPreferencesManager.js new file mode 100644 index 00000000..91274312 --- /dev/null +++ b/src/js/plugins/pluginPreferencesManager.js @@ -0,0 +1,84 @@ +// Handles persisting plugin settings in user preferences +export class PluginPreferencesManager { + constructor(preferencesProvider) { + this.preferencesProvider = preferencesProvider; + } + + getInstalledPlugins() { + return this.preferencesProvider.requirePreferences().getInstalledPlugins(); + } + + async setInstalledPlugins(plugins) { + const preferences = this.preferencesProvider + .requirePreferences() + .setInstalledPlugins(plugins); + await this.preferencesProvider.savePreferences(preferences); + } + + getInstalledPlugin(pluginId) { + return this.getInstalledPlugins().find((plugin) => plugin.id === pluginId); + } + + getEnabledPlugins() { + return this.getInstalledPlugins().filter((entry) => entry.enabled); + } + + async addInstalledPlugin(plugin) { + const installedPlugins = this.getInstalledPlugins(); + await this.setInstalledPlugins([...installedPlugins, plugin]); + } + + async removeInstalledPlugin(pluginId) { + const installedPlugins = this.getInstalledPlugins(); + await this.setInstalledPlugins( + installedPlugins.filter((plugin) => plugin.id !== pluginId), + ); + } + + async updateInstalledPlugin(pluginId, updateFunc) { + const installedPlugins = this.getInstalledPlugins(); + if (!installedPlugins.some((plugin) => plugin.id === pluginId)) { + throw new Error( + `Tried to update preference for uninstalled plugin: ${pluginId}`, + ); + } + const updated = installedPlugins.map((plugin) => + plugin.id === pluginId ? updateFunc(plugin) : plugin, + ); + await this.setInstalledPlugins(updated); + } + + async setPluginDisabled(pluginId) { + await this.updateInstalledPlugin(pluginId, (entry) => ({ + ...entry, + enabled: false, + })); + } + + async setPluginEnabled(pluginId) { + await this.updateInstalledPlugin(pluginId, (entry) => ({ + ...entry, + enabled: true, + })); + } + + readSettingsForPlugin(pluginId) { + return this.preferencesProvider + .requirePreferences() + .getPluginSettings(pluginId); + } + + async writeSettingsForPlugin(pluginId, data) { + const preferences = this.preferencesProvider + .requirePreferences() + .setPluginSettings(pluginId, data); + await this.preferencesProvider.savePreferences(preferences); + } + + async clearSettingsForPlugin(pluginId) { + const preferences = this.preferencesProvider + .requirePreferences() + .clearPluginSettings(pluginId); + await this.preferencesProvider.savePreferences(preferences); + } +} diff --git a/src/js/plugins/pluginRegistry.js b/src/js/plugins/pluginRegistry.js index c1592129..6fedbe65 100644 --- a/src/js/plugins/pluginRegistry.js +++ b/src/js/plugins/pluginRegistry.js @@ -1,56 +1,46 @@ -import { isDev } from "/js/utils.js"; - const CACHE_TTL_MS = 120_000; -const LOCAL_INDEX_URL = "/plugins-local/index.json"; -export class PluginRegistry { - constructor(url, { fetchImpl } = {}) { +class PluginRegistry { + async getListings() { + throw new Error("not implemented"); + } + async getListing(id) { + const listings = await this.getListings(); + return listings.find((listing) => listing.id === id) ?? null; + } +} + +export class RemotePluginRegistry extends PluginRegistry { + constructor(url) { + super(); this.url = url; - this._fetch = fetchImpl ?? ((...args) => window.fetch(...args)); this._cache = null; } - async getPluginListings({ force = false } = {}) { - if ( - !force && - this._cache && - Date.now() - this._cache.fetchedAt < CACHE_TTL_MS - ) { + async getListings() { + if (this._cache && Date.now() - this._cache.fetchedAt < CACHE_TTL_MS) { return this._cache.listings; } - const [remoteListings, localListings] = await Promise.all([ - this._fetchRemoteListings(), - isDev() ? this._fetchLocalListings() : [], - ]); - const localSet = new Set(localListings.map((listing) => listing.id)); - const listings = [ - ...localListings.map((listing) => ({ ...listing, local: true })), - ...remoteListings.filter((listing) => !localSet.has(listing.id)), - ]; + const listings = await this._fetchListings(); this._cache = { fetchedAt: Date.now(), listings }; return listings; } - async _fetchRemoteListings() { - const response = await this._fetch(this.url, { cache: "no-store" }); + async _fetchListings() { + const response = await fetch(this.url, { cache: "no-store" }); if (!response.ok) throw new Error(`registry HTTP ${response.status}`); - const body = await response.json(); - return Array.isArray(body) ? body : []; + return response.json(); } +} - async _fetchLocalListings() { - try { - const response = await this._fetch(LOCAL_INDEX_URL); - if (!response.ok) return []; - const body = await response.json(); - return Array.isArray(body) ? body : []; - } catch { - return []; - } - } +const LOCAL_INDEX_URL = "/plugins-local/index.json"; - async getPluginListing(id, opts) { - const all = await this.getPluginListings(opts); - return all.find((listing) => listing.id === id) ?? null; +export class LocalPluginRegistry extends PluginRegistry { + async getListings() { + const response = await fetch(LOCAL_INDEX_URL); + if (!response.ok) { + throw new Error(`local registry HTTP ${response.status}`); + } + return response.json(); } } diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index f355b038..92dbd05e 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -2,10 +2,14 @@ import { PluginBridge } from "/js/plugins/pluginBridge.js"; import { showPluginModal, hidePluginModal } from "/js/modals.js"; import { showPluginToast, hidePluginToast, showToast } from "/js/toasts.js"; import { PluginRenderer } from "/js/plugins/pluginRendering.js"; -import { PluginRegistry } from "/js/plugins/pluginRegistry.js"; +import { + RemotePluginRegistry, + LocalPluginRegistry, +} from "/js/plugins/pluginRegistry.js"; import { PluginCache } from "/js/plugins/pluginCache.js"; +import { PluginPreferencesManager } from "/js/plugins/pluginPreferencesManager.js"; import { SourceProvider } from "/js/plugins/sourceProvider.js"; -import { compareVersions } from "/js/utils.js"; +import { compareVersions, isDev } from "/js/utils.js"; import { EventEmitter } from "/js/eventEmitter.js"; import { PLUGIN_REGISTRY_URL } from "/js/config.js"; @@ -18,36 +22,19 @@ export class PluginService extends EventEmitter { feedFilters: new Set(), settingTabs: new Map(), }; - this._pluginsInfo = null; this._availableUpdates = null; - this.registry = new PluginRegistry(PLUGIN_REGISTRY_URL); + this.remoteRegistry = new RemotePluginRegistry(PLUGIN_REGISTRY_URL); + this.localRegistry = isDev() ? new LocalPluginRegistry() : null; this.pluginCache = new PluginCache(); - this.sourceProvider = new SourceProvider(this.registry, this.pluginCache); + this.sourceProvider = new SourceProvider(this.pluginCache); this.pluginBridge = new PluginBridge(this.sourceProvider); this.pluginRenderer = new PluginRenderer(this.pluginBridge); - this.preferencesProvider = preferencesProvider; + this.prefManager = new PluginPreferencesManager(preferencesProvider); this.session = session; this._setupRegistries(); this._setupHostMethods(); } - _readPluginSettings(pluginId) { - const prefs = this.preferencesProvider.requirePreferences(); - return prefs.getPluginSettings(pluginId); - } - - async _writePluginSettings(pluginId, data) { - if (!this.preferencesProvider) { - throw new Error("Preferences not available"); - } - const preferences = this.preferencesProvider - .requirePreferences() - .setPluginSettings(pluginId, data); - await this.preferencesProvider.savePreferences(preferences); - const instance = this.pluginBridge.getInstance(pluginId); - if (instance) instance.sendEvent("settingsChanged", { data }); - } - _setupRegistries() { this.pluginBridge.addRegistrationTarget( "sidebarItem", @@ -124,11 +111,11 @@ export class PluginService extends EventEmitter { }); this.pluginBridge.addHostMethod("loadData", (plugin) => { - return this._readPluginSettings(plugin.pluginId); + return this.prefManager.readSettingsForPlugin(plugin.pluginId); }); this.pluginBridge.addHostMethod("saveData", async (plugin, { data }) => { - await this._writePluginSettings(plugin.pluginId, data); + await this.prefManager.writeSettingsForPlugin(plugin.pluginId, data); }); this.pluginBridge.addHostMethod("refreshSettingTab", (plugin) => { @@ -161,16 +148,26 @@ export class PluginService extends EventEmitter { }); } - getSettingTabs() { - return [...this.registries.settingTabs.values()]; - } - - getSettingTab(pluginId) { - return this.registries.settingTabs.get(pluginId) ?? null; - } - - getPluginsInfo() { - return this._pluginsInfo; + async loadEnabledPlugins() { + const enabledPlugins = this.prefManager.getEnabledPlugins(); + const { erroredPlugins } = + await this.pluginBridge.loadPlugins(enabledPlugins); + if (erroredPlugins.length) { + const failedPluginIds = erroredPlugins.map(({ pluginId }) => pluginId); + showToast(`Failed to load plugin(s): ${failedPluginIds.join(", ")}`, { + style: "error", + }); + // Disable plugins that failed to load + await Promise.all( + failedPluginIds.map((pluginId) => + this.prefManager.setPluginDisabled(pluginId), + ), + ); + } + // Reconcile against all installed plugins (not just enabled) so disabled + // plugins keep their cached assets on re-enable + const installedPlugins = this.prefManager.getInstalledPlugins(); + await this._reconcileCache(installedPlugins); } getAvailableUpdates() { @@ -178,15 +175,12 @@ export class PluginService extends EventEmitter { } async checkForUpdates() { - const installed = this._getInstalledPluginsPreference(); + const installedPlugins = this.prefManager.getInstalledPlugins(); const results = await Promise.allSettled( - installed.map(async (entry) => { - const listing = await this.registry - .getPluginListing(entry.id) - .catch(() => null); - if (listing?.local) return null; + installedPlugins.map(async (entry) => { const liveManifest = await this.sourceProvider.getLiveManifest( entry.id, + entry.repo, ); if (compareVersions(liveManifest.version, entry.version) > 0) { return { id: entry.id, version: liveManifest.version }; @@ -205,205 +199,151 @@ export class PluginService extends EventEmitter { } async reloadPlugins() { - const installedPluginsPreference = this._getInstalledPluginsPreference(); + const installedPlugins = this.prefManager.getInstalledPlugins(); const results = await Promise.allSettled( - installedPluginsPreference + installedPlugins .filter((entry) => entry.enabled === true) .map(async (entry) => { - this.pluginBridge.unloadPlugin(entry.id); try { - await this.pluginBridge.loadPlugin(entry.id, entry.version); + await this.pluginBridge.reloadPlugin( + entry.id, + entry.version, + entry.repo, + ); } catch (e) { - await this._setPluginDisabled(entry.id); + await this.prefManager.setPluginDisabled(entry.id); throw e; } }), ); - await this.loadPluginsInfo(); const failure = results.find((result) => result.status === "rejected"); if (failure) throw failure.reason; } - async loadPluginsInfo() { - const installedPluginsPreference = this._getInstalledPluginsPreference(); - this._pluginsInfo = await Promise.all( - installedPluginsPreference.map(async (entry) => { - const [manifest, listing] = await Promise.all([ - this.sourceProvider - .getManifest(entry.id, entry.version) - .catch(() => null), - this.registry.getPluginListing(entry.id).catch(() => null), - ]); - return { - id: entry.id, - name: manifest?.name ?? entry.id, - description: - manifest?.description ?? "Failed to load plugin manifest", - version: manifest?.version ?? "-", - author: manifest?.author ?? "Unknown", - enabled: entry.enabled === true, - loaded: this.pluginBridge.isLoaded(entry.id), - hasSettings: this.registries.settingTabs.has(entry.id), - local: listing?.local === true, - }; - }), - ); - } - - async loadEnabledPlugins() { - const installedPluginsPreference = this._getInstalledPluginsPreference(); - const toLoad = installedPluginsPreference.filter((entry) => entry.enabled); - const { erroredPlugins } = await this.pluginBridge.loadPlugins(toLoad); - if (erroredPlugins.length) { - const failedPluginIds = erroredPlugins.map(({ pluginId }) => pluginId); - showToast(`Failed to load plugin(s): ${failedPluginIds.join(", ")}`, { - style: "error", - }); - // Disable plugins that failed to load - await Promise.all( - failedPluginIds.map((pluginId) => this._setPluginDisabled(pluginId)), - ); - } - // Reconcile against all installed plugins (not just enabled) so disabled - // plugins keep their cached assets on re-enable - await this._reconcileCache(installedPluginsPreference); + getPluginsInfo() { + const installedPlugins = this.prefManager.getInstalledPlugins(); + return installedPlugins.map((entry) => { + return { + id: entry.id, + name: entry.name, + description: entry.description, + version: entry.version, + author: entry.author, + enabled: entry.enabled, + loaded: this.pluginBridge.isLoaded(entry.id), + hasSettings: this.registries.settingTabs.has(entry.id), + }; + }); } async getManifest(pluginId) { - const installedPluginsPreference = - this._getInstalledPluginsPreference().find( - (plugin) => plugin.id === pluginId, - ); + const installedPlugin = this.prefManager + .getInstalledPlugins() + .find((plugin) => plugin.id === pluginId); return this.sourceProvider - .getManifest(pluginId, installedPluginsPreference?.version) + .getManifest(pluginId, installedPlugin?.version, installedPlugin?.repo) .catch(() => null); } - _getInstalledPluginsPreference() { - if (!this.preferencesProvider) return []; - try { - return this.preferencesProvider - .requirePreferences() - .getInstalledPlugins(); - } catch { - return []; - } - } - - async _setInstalledPluginsPreference(plugins) { - const preferences = this.preferencesProvider - .requirePreferences() - .setInstalledPlugins(plugins); - await this.preferencesProvider.savePreferences(preferences); - } - async _reconcileCache(installed) { const urlLists = await Promise.all( installed.map((entry) => - this.sourceProvider.getCacheUrls(entry.id, entry.version), + this.sourceProvider.getCacheUrls(entry.id, entry.version, entry.repo), ), ); await this.pluginCache.reconcile(urlLists.flat()); } async installPlugin(pluginId) { - const listing = await this.registry.getPluginListing(pluginId); - if (!listing) { - throw new Error(`unknown plugin: ${pluginId}`); + let repo = null; + if (!pluginId.endsWith("__LOCAL")) { + const listing = await this.remoteRegistry.getListing(pluginId); + if (!listing) { + throw new Error(`unknown plugin: ${pluginId}`); + } + repo = listing.repo; } - const installedPluginsPreference = this._getInstalledPluginsPreference(); - if (installedPluginsPreference.some((plugin) => plugin.id === pluginId)) + const installedPlugins = this.prefManager.getInstalledPlugins(); + if (installedPlugins.some((plugin) => plugin.id === pluginId)) { + console.warn(`Plugin ${pluginId} already installed`); return; + } let manifest = null; try { - manifest = listing.local - ? await this.sourceProvider.getManifest(pluginId) - : await this.sourceProvider.getLiveManifest(pluginId); + manifest = await this.sourceProvider.getLiveManifest(pluginId, repo); } catch (e) { console.error("Failed to fetch manifest", e); throw new Error("Failed to fetch manifest"); } - const version = manifest.version; - await this._addPluginPreferenceEntry({ + const { name, version, author, description } = manifest; + await this.prefManager.addInstalledPlugin({ id: pluginId, + name, version, + author, + description, + repo, enabled: true, }); try { - await this.pluginBridge.loadPlugin(pluginId, version); + await this.pluginBridge.loadPlugin(pluginId, version, repo); } catch (e) { - await this._removePluginPreferenceEntry(pluginId); + console.error(e); + await this.prefManager.removeInstalledPlugin(pluginId); throw e; } } async uninstallPlugin(pluginId) { this.pluginBridge.unloadPlugin(pluginId); - await this._removePluginPreferenceEntry(pluginId); - await this._clearPluginSettings(pluginId); - await this._reconcileCache(this._getInstalledPluginsPreference()); - } - - async _clearPluginSettings(pluginId) { - const preferences = this.preferencesProvider - .requirePreferences() - .clearPluginSettings(pluginId); - await this.preferencesProvider.savePreferences(preferences); + await this.prefManager.removeInstalledPlugin(pluginId); + await this.prefManager.clearSettingsForPlugin(pluginId); + await this._reconcileCache(this.prefManager.getInstalledPlugins()); } async enablePlugin(pluginId) { - await this._setPluginEnabled(pluginId); - const entry = this._getInstalledPluginsPreference().find( - (plugin) => plugin.id === pluginId, - ); + await this.prefManager.setPluginEnabled(pluginId); + const installedPlugin = this.prefManager.getInstalledPlugin(pluginId); try { - await this.pluginBridge.loadPlugin(pluginId, entry.version); + await this.pluginBridge.loadPlugin( + pluginId, + installedPlugin.version, + installedPlugin.repo, + ); } catch (e) { - await this._setPluginDisabled(pluginId); + await this.prefManager.setPluginDisabled(pluginId); throw e; } } - async _setPluginEnabled(pluginId) { - await this._updatePluginPreferenceEntry(pluginId, (entry) => ({ - ...entry, - enabled: true, - })); - } - async disablePlugin(pluginId) { this.pluginBridge.unloadPlugin(pluginId); - await this._setPluginDisabled(pluginId); - } - - async _setPluginDisabled(pluginId) { - await this._updatePluginPreferenceEntry(pluginId, (entry) => ({ - ...entry, - enabled: false, - })); + await this.prefManager.setPluginDisabled(pluginId); } async updatePlugin(pluginId) { - const installedPluginsPreference = - this._getInstalledPluginsPreference().find( - (plugin) => plugin.id === pluginId, - ); - if (!installedPluginsPreference) return null; - const liveManifest = await this.sourceProvider.getLiveManifest(pluginId); - if ( - compareVersions( - liveManifest.version, - installedPluginsPreference.version, - ) > 0 - ) { - const newVersion = liveManifest.version; - await this._updatePluginPreferenceEntry(pluginId, (entry) => ({ + const installedPlugin = this.prefManager.getInstalledPlugin(pluginId); + if (!installedPlugin) return null; + const liveManifest = await this.sourceProvider.getLiveManifest( + pluginId, + installedPlugin.repo, + ); + if (compareVersions(liveManifest.version, installedPlugin.version) > 0) { + const { name, version, author, description } = liveManifest; + await this.prefManager.updateInstalledPlugin(pluginId, (entry) => ({ ...entry, - version: newVersion, + name, + version, + author, + description, })); - await this.pluginBridge.reloadPlugin(pluginId, newVersion); + await this.pluginBridge.reloadPlugin( + pluginId, + version, + installedPlugin.repo, + ); this._availableUpdates?.delete(pluginId); - return { updated: true, version: newVersion }; + return { updated: true, version }; } this._availableUpdates?.delete(pluginId); return { updated: false }; @@ -414,73 +354,48 @@ export class PluginService extends EventEmitter { return { updated: [], failed: [] }; } const ids = [...this._availableUpdates.keys()]; - const results = await Promise.allSettled( - ids.map((pluginId) => this.updatePlugin(pluginId)), - ); const updated = []; const failed = []; - results.forEach((result, index) => { - const pluginId = ids[index]; - if (result.status === "fulfilled" && result.value?.updated) { - updated.push(pluginId); - } else if (result.status === "rejected") { + // Serial to avoid racing read-modify-write on installed plugin preferences + for (const pluginId of ids) { + try { + const result = await this.updatePlugin(pluginId); + if (result?.updated) updated.push(pluginId); + } catch { failed.push(pluginId); } - }); - return { updated, failed }; - } - - async _addPluginPreferenceEntry(entry) { - const installedPluginsPreference = this._getInstalledPluginsPreference(); - await this._setInstalledPluginsPreference([ - ...installedPluginsPreference, - entry, - ]); - } - - async _removePluginPreferenceEntry(pluginId) { - const next = this._getInstalledPluginsPreference().filter( - (plugin) => plugin.id !== pluginId, - ); - await this._setInstalledPluginsPreference(next); - } - - async _updatePluginPreferenceEntry(pluginId, updateFunc) { - const installedPluginsPreference = this._getInstalledPluginsPreference(); - if (!installedPluginsPreference.some((plugin) => plugin.id === pluginId)) { - throw new Error( - `Tried to update preference for uninstalled plugin: ${pluginId}`, - ); } - const updated = installedPluginsPreference.map((plugin) => - plugin.id === pluginId ? updateFunc(plugin) : plugin, - ); - await this._setInstalledPluginsPreference(updated); + return { updated, failed }; } async listRegistryPlugins() { - const listings = await this.registry.getPluginListings(); - const installedIds = new Set( - this._getInstalledPluginsPreference().map((entry) => entry.id), - ); - return listings.map((listing) => ({ + const remoteListings = await this.remoteRegistry.getListings(); + const localListings = this.localRegistry + ? await this.localRegistry.getListings() + : []; + const installedIds = this.prefManager + .getInstalledPlugins() + .map((entry) => entry.id); + return [...remoteListings, ...localListings].map((listing) => ({ ...listing, - installed: installedIds.has(listing.id), + installed: installedIds.includes(listing.id), })); } - getEnabledPlugins() { - return this._getInstalledPluginsPreference() - .filter((entry) => entry.enabled) - .map((entry) => entry.id); - } - // Registry convenience methods getSidebarItems() { return [...this.registries.sidebarItems]; } + getSettingTabs() { + return [...this.registries.settingTabs.values()]; + } + + getSettingTab(pluginId) { + return this.registries.settingTabs.get(pluginId) ?? null; + } + async getPostContextMenuItems(post) { return this._collectContextMenuItems("post-context-menu", post); } diff --git a/src/js/plugins/sourceProvider.js b/src/js/plugins/sourceProvider.js index 0050dfa8..f76edf31 100644 --- a/src/js/plugins/sourceProvider.js +++ b/src/js/plugins/sourceProvider.js @@ -1,7 +1,3 @@ -// Loads a plugin's manifest and source code. Routes per-plugin based on -// the registry listing's `local` flag: local plugins come from /plugins-local/, -// remote plugins come from GitHub release assets via the plugin cache. - const REQUIRED_MANIFEST_FIELDS = ["id", "name", "version"]; function parsePluginManifest(pluginId, manifest) { @@ -23,74 +19,63 @@ function remoteAssetUrl(repo, tag, file) { } export class SourceProvider { - constructor(registry, pluginCache, { fetchImpl } = {}) { - this.registry = registry; + constructor(pluginCache) { this.pluginCache = pluginCache; - this._fetch = fetchImpl ?? ((...args) => window.fetch(...args)); - } - - async _resolveListing(pluginId) { - const listing = await this.registry.getPluginListing(pluginId); - if (!listing) throw new Error(`not in registry: ${pluginId}`); - return listing; - } - - async getManifest(pluginId, version) { - const listing = await this._resolveListing(pluginId); - return this._fetchManifest(pluginId, listing, version); } - async _fetchManifest(pluginId, listing, version) { - if (listing.local) { - const response = await this._fetch( - `/plugins-local/${pluginId}/manifest.json`, - ); + async getManifest(pluginId, version, repo) { + if (pluginId.endsWith("__LOCAL")) { + const response = await fetch(`/plugins-local/${pluginId}/manifest.json`); if (!response.ok) throw new Error(`HTTP ${response.status}`); - return parsePluginManifest(pluginId, await response.json()); + const manifest = await response.json(); + manifest.id = manifest.id + "__LOCAL"; + return parsePluginManifest(pluginId, manifest); } - if (!version) throw new Error(`version required: ${pluginId}`); - const url = remoteAssetUrl(listing.repo, version, "manifest.json"); + if (!version || !repo) { + throw new Error("Version and repo are required"); + } + const url = remoteAssetUrl(repo, version, "manifest.json"); const response = await this.pluginCache.fetch(url); return parsePluginManifest(pluginId, await response.json()); } - async getLiveManifest(pluginId) { - const listing = await this._resolveListing(pluginId); - if (listing.local) { - const response = await this._fetch( - `/plugins-local/${pluginId}/manifest.json`, - ); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return parsePluginManifest(pluginId, await response.json()); + async getLiveManifest(pluginId, repo) { + if (pluginId.endsWith("__LOCAL")) { + return this.getManifest(pluginId, null, null); + } + if (!repo) { + throw new Error("Repo is required"); } - const url = remoteAssetUrl(listing.repo, "main", "manifest.json"); - const response = await this._fetch(url, { cache: "no-store" }); + // Fetch from main branch + const url = remoteAssetUrl(repo, "main", "manifest.json"); + const response = await fetch(url, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return parsePluginManifest(pluginId, await response.json()); } - async getSource(pluginId, version) { - const listing = await this._resolveListing(pluginId); - if (listing.local) { - const response = await this._fetch(`/plugins-local/${pluginId}/main.js`); + async getSource(pluginId, version, repo) { + if (pluginId.endsWith("__LOCAL")) { + const response = await fetch(`/plugins-local/${pluginId}/main.js`); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.text(); } - if (!version) throw new Error(`version required: ${pluginId}`); - const url = remoteAssetUrl(listing.repo, version, "main.js"); + if (!version || !repo) { + throw new Error("Version and repo are required"); + } + const url = remoteAssetUrl(repo, version, "main.js"); const response = await this.pluginCache.fetch(url); return await response.text(); } // URLs that should be retained in the cache // Local plugins have no cached URLs - async getCacheUrls(pluginId, version) { - const listing = await this.registry.getPluginListing(pluginId); - if (!listing || listing.local) return []; - if (!version) return []; + async getCacheUrls(pluginId, version, repo) { + if (pluginId.endsWith("__LOCAL")) { + return []; + } return [ - remoteAssetUrl(listing.repo, version, "manifest.json"), - remoteAssetUrl(listing.repo, version, "main.js"), + remoteAssetUrl(repo, version, "manifest.json"), + remoteAssetUrl(repo, version, "main.js"), ]; } } diff --git a/src/js/views/settings/communityPlugins.view.js b/src/js/views/settings/communityPlugins.view.js index 89eefc98..8ba96a43 100644 --- a/src/js/views/settings/communityPlugins.view.js +++ b/src/js/views/settings/communityPlugins.view.js @@ -31,6 +31,7 @@ class SettingsCommunityPluginsView extends View { try { state.entries = await pluginService.listRegistryPlugins(); } catch (error) { + console.error(error); state.error = error.message ?? String(error); } renderPage(); @@ -64,7 +65,8 @@ class SettingsCommunityPluginsView extends View { : `Installed ${entry.name}`, { style: wasInstalled ? "default" : "success" }, ); - } catch (error) { + } catch (e) { + console.error(e); showToast( wasInstalled ? `Failed to uninstall ${entry.name}` @@ -98,13 +100,13 @@ class SettingsCommunityPluginsView extends View { onClickBackButton: () => window.router.go("/settings/plugins"), })}
- ${!state.entries - ? "" // loading is usually quick, so don't show a loading state - : state.error - ? html`
-
Failed to load plugins
- -
` + ${state.error + ? html`
+
Failed to load plugins
+ +
` + : !state.entries + ? "" // loading is usually quick, so don't show a loading state : state.entries.length === 0 ? html`
@@ -125,7 +127,7 @@ class SettingsCommunityPluginsView extends View {
${entry.name} - ${entry.local + ${entry.id.endsWith("__LOCAL") ? html`local` diff --git a/src/js/views/settings/plugins.view.js b/src/js/views/settings/plugins.view.js index c91d8dca..c6f28981 100644 --- a/src/js/views/settings/plugins.view.js +++ b/src/js/views/settings/plugins.view.js @@ -34,12 +34,6 @@ class SettingsPluginsView extends View { updatingAll: false, updatingIds: new Set(), }; - - async function loadPlugins() { - await pluginService.loadPluginsInfo(); - renderPage(); - } - async function uninstallPlugin(plugin) { const confirmed = await confirm( `"${plugin.name}" will be uninstalled and its settings will be deleted.`, @@ -54,7 +48,6 @@ class SettingsPluginsView extends View { renderPage(); try { await pluginService.uninstallPlugin(plugin.id); - await loadPlugins(); showToast(`Uninstalled ${plugin.name}`); } finally { state.uninstallingIds.delete(plugin.id); @@ -70,6 +63,7 @@ class SettingsPluginsView extends View { await pluginService.reloadPlugins(); showToast("Reloaded plugins"); } catch (e) { + console.error(e); showToast("Failed to reload plugins", { style: "error" }); } finally { state.reloading = false; @@ -107,9 +101,9 @@ class SettingsPluginsView extends View { showToast(`Updated ${plugin.name} to v${result.version}`, { style: "success", }); - await loadPlugins(); } } catch (e) { + console.error(e); showToast(`Failed to update ${plugin.name}`, { style: "error", }); @@ -135,7 +129,6 @@ class SettingsPluginsView extends View { { style: "success" }, ); } - await loadPlugins(); } finally { state.updatingAll = false; renderPage(); @@ -162,7 +155,6 @@ class SettingsPluginsView extends View { }); } } - await loadPlugins(); } finally { pendingSet.delete(plugin.id); renderPage(); @@ -240,10 +232,13 @@ class SettingsPluginsView extends View { : checkForUpdates()} > ${state.checkingForUpdates || state.updatingAll - ? html`
` + ? html`${hasAvailableUpdates + ? "Updating..." + : "Checking..."} +
` : hasAvailableUpdates ? "Update all" : "Check for updates"} @@ -282,7 +277,7 @@ class SettingsPluginsView extends View {
${plugin.name} - ${plugin.local + ${plugin.id.endsWith("__LOCAL") ? html`local` @@ -360,13 +355,11 @@ class SettingsPluginsView extends View { root.addEventListener("page-enter", async () => { renderPage(); dataLayer.declarative.ensureCurrentUser().then(() => renderPage()); - await loadPlugins(); }); root.addEventListener("page-restore", () => { window.scrollTo(0, 0); renderPage(); - loadPlugins(); }); notificationService?.on("update", () => renderPage()); diff --git a/tests/unit/specs/pluginCache.test.js b/tests/unit/specs/pluginCache.test.js index be1b5a64..cd7599fd 100644 --- a/tests/unit/specs/pluginCache.test.js +++ b/tests/unit/specs/pluginCache.test.js @@ -47,42 +47,80 @@ function makeResponse(body, { ok = true, status = 200 } = {}) { }; } +// Installs a stub for `fetch` on globalThis and window. Returns +// `{ calls, restore }` so tests can inspect requests and clean up. +function stubFetch(handler) { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, options }); + return handler(url, options); + }; + const originalGlobal = globalThis.fetch; + const originalWindow = globalThis.window.fetch; + globalThis.fetch = fetchImpl; + globalThis.window.fetch = fetchImpl; + return { + calls, + restore() { + globalThis.fetch = originalGlobal; + globalThis.window.fetch = originalWindow; + }, + }; +} + +// Installs a fresh FakeCaches on globalThis and window. Returns the fake plus +// a restore function. +function stubCaches() { + const fakeCaches = new FakeCaches(); + const originalGlobal = globalThis.caches; + const originalWindow = globalThis.window.caches; + globalThis.caches = fakeCaches; + globalThis.window.caches = fakeCaches; + return { + caches: fakeCaches, + restore() { + globalThis.caches = originalGlobal; + globalThis.window.caches = originalWindow; + }, + }; +} + const t = new TestSuite("pluginCache"); -t.describe("PluginCache.fetch", (it) => { +t.describe("PluginCache.fetch", (it, { beforeEach, afterEach }) => { + let fetchStub; + let cachesStub; + beforeEach(() => { + cachesStub = stubCaches(); + }); + afterEach(() => { + fetchStub?.restore(); + cachesStub.restore(); + }); + it("fetches on miss and stores in cache", async () => { - const caches = new FakeCaches(); - let fetchCount = 0; - const fetchImpl = async () => { - fetchCount++; - return makeResponse("hello"); - }; - const cache = new PluginCache({ cachesImpl: caches, fetchImpl }); + fetchStub = stubFetch(async () => makeResponse("hello")); + const cache = new PluginCache(); const response = await cache.fetch("https://example.test/a.js"); assertEquals(await response.text(), "hello"); - assertEquals(fetchCount, 1); - const bucket = await caches.open("plugins-v1"); + assertEquals(fetchStub.calls.length, 1); + const bucket = await cachesStub.caches.open("plugins-v1"); assert(await bucket.match("https://example.test/a.js")); }); it("reuses cached response on hit", async () => { - const caches = new FakeCaches(); - let fetchCount = 0; - const fetchImpl = async () => { - fetchCount++; - return makeResponse("hello"); - }; - const cache = new PluginCache({ cachesImpl: caches, fetchImpl }); + fetchStub = stubFetch(async () => makeResponse("hello")); + const cache = new PluginCache(); await cache.fetch("https://example.test/a.js"); await cache.fetch("https://example.test/a.js"); - assertEquals(fetchCount, 1); + assertEquals(fetchStub.calls.length, 1); }); it("throws on non-OK responses and does not cache them", async () => { - const caches = new FakeCaches(); - const fetchImpl = async () => - makeResponse("nope", { ok: false, status: 404 }); - const cache = new PluginCache({ cachesImpl: caches, fetchImpl }); + fetchStub = stubFetch(async () => + makeResponse("nope", { ok: false, status: 404 }), + ); + const cache = new PluginCache(); let threw = false; try { await cache.fetch("https://example.test/missing.js"); @@ -91,28 +129,32 @@ t.describe("PluginCache.fetch", (it) => { assert(error.message.includes("404")); } assert(threw); - const bucket = await caches.open("plugins-v1"); + const bucket = await cachesStub.caches.open("plugins-v1"); assertEquals((await bucket.keys()).length, 0); }); }); -t.describe("PluginCache.reconcile", (it) => { +t.describe("PluginCache.reconcile", (it, { beforeEach, afterEach }) => { + let cachesStub; + beforeEach(() => { + cachesStub = stubCaches(); + }); + afterEach(() => cachesStub.restore()); + it("deletes entries not in the wanted set", async () => { - const caches = new FakeCaches(); - const bucket = await caches.open("plugins-v1"); + const bucket = await cachesStub.caches.open("plugins-v1"); await bucket.put("https://x.test/keep.js", makeResponse("k")); await bucket.put("https://x.test/old.js", makeResponse("o")); - const cache = new PluginCache({ cachesImpl: caches }); + const cache = new PluginCache(); await cache.reconcile(["https://x.test/keep.js"]); const remaining = (await bucket.keys()).map((request) => request.url); assertEquals(remaining, ["https://x.test/keep.js"]); }); it("keeps wanted entries even if not all are present", async () => { - const caches = new FakeCaches(); - const bucket = await caches.open("plugins-v1"); + const bucket = await cachesStub.caches.open("plugins-v1"); await bucket.put("https://x.test/keep.js", makeResponse("k")); - const cache = new PluginCache({ cachesImpl: caches }); + const cache = new PluginCache(); await cache.reconcile([ "https://x.test/keep.js", "https://x.test/not-yet-fetched.js", diff --git a/tests/unit/specs/pluginPreferencesManager.test.js b/tests/unit/specs/pluginPreferencesManager.test.js new file mode 100644 index 00000000..0c76d952 --- /dev/null +++ b/tests/unit/specs/pluginPreferencesManager.test.js @@ -0,0 +1,219 @@ +import { TestSuite } from "../testSuite.js"; +import { assert, assertEquals } from "../testHelpers.js"; +import { PluginPreferencesManager } from "/js/plugins/pluginPreferencesManager.js"; + +// A minimal fake of the Preferences object the manager interacts with. +// The real Preferences mutates an underlying object and returns `this` from +// setters; the fake mirrors that so the manager can chain set+save. +class FakePreferences { + constructor(state) { + this.state = state; + } + + getInstalledPlugins() { + return this.state.installedPlugins; + } + + setInstalledPlugins(plugins) { + this.state.installedPlugins = plugins; + return this; + } + + getPluginSettings(pluginId) { + return this.state.pluginSettings[pluginId]; + } + + setPluginSettings(pluginId, data) { + this.state.pluginSettings[pluginId] = data; + return this; + } + + clearPluginSettings(pluginId) { + delete this.state.pluginSettings[pluginId]; + return this; + } +} + +function makeProvider({ installedPlugins = [], pluginSettings = {} } = {}) { + const state = { installedPlugins, pluginSettings }; + const preferences = new FakePreferences(state); + const saveCalls = []; + return { + state, + preferences, + saveCalls, + provider: { + requirePreferences: () => preferences, + savePreferences: async (prefs) => { + saveCalls.push(prefs); + }, + }, + }; +} + +const t = new TestSuite("pluginPreferencesManager"); + +t.describe("installed plugins", (it) => { + it("returns installed plugins from preferences", () => { + const { provider } = makeProvider({ + installedPlugins: [{ id: "a", enabled: true }], + }); + const manager = new PluginPreferencesManager(provider); + assertEquals(manager.getInstalledPlugins(), [{ id: "a", enabled: true }]); + }); + + it("setInstalledPlugins persists via savePreferences", async () => { + const { provider, saveCalls, state, preferences } = makeProvider(); + const manager = new PluginPreferencesManager(provider); + await manager.setInstalledPlugins([{ id: "a", enabled: true }]); + assertEquals(state.installedPlugins, [{ id: "a", enabled: true }]); + assertEquals(saveCalls.length, 1); + assert(saveCalls[0] === preferences); + }); + + it("getInstalledPlugin finds by id", () => { + const { provider } = makeProvider({ + installedPlugins: [ + { id: "a", enabled: true }, + { id: "b", enabled: false }, + ], + }); + const manager = new PluginPreferencesManager(provider); + assertEquals(manager.getInstalledPlugin("b"), { id: "b", enabled: false }); + assertEquals(manager.getInstalledPlugin("missing"), undefined); + }); + + it("getEnabledPlugins filters to enabled entries", () => { + const { provider } = makeProvider({ + installedPlugins: [ + { id: "a", enabled: true }, + { id: "b", enabled: false }, + { id: "c", enabled: true }, + ], + }); + const manager = new PluginPreferencesManager(provider); + assertEquals(manager.getEnabledPlugins(), [ + { id: "a", enabled: true }, + { id: "c", enabled: true }, + ]); + }); + + it("addInstalledPlugin appends and saves", async () => { + const { provider, state, saveCalls } = makeProvider({ + installedPlugins: [{ id: "a", enabled: true }], + }); + const manager = new PluginPreferencesManager(provider); + await manager.addInstalledPlugin({ id: "b", enabled: false }); + assertEquals(state.installedPlugins, [ + { id: "a", enabled: true }, + { id: "b", enabled: false }, + ]); + assertEquals(saveCalls.length, 1); + }); + + it("removeInstalledPlugin removes by id and saves", async () => { + const { provider, state, saveCalls } = makeProvider({ + installedPlugins: [ + { id: "a", enabled: true }, + { id: "b", enabled: false }, + ], + }); + const manager = new PluginPreferencesManager(provider); + await manager.removeInstalledPlugin("a"); + assertEquals(state.installedPlugins, [{ id: "b", enabled: false }]); + assertEquals(saveCalls.length, 1); + }); + + it("removeInstalledPlugin is a no-op when id is absent", async () => { + const { provider, state } = makeProvider({ + installedPlugins: [{ id: "a", enabled: true }], + }); + const manager = new PluginPreferencesManager(provider); + await manager.removeInstalledPlugin("missing"); + assertEquals(state.installedPlugins, [{ id: "a", enabled: true }]); + }); +}); + +t.describe("updateInstalledPlugin", (it) => { + it("applies updateFunc to the matching entry only", async () => { + const { provider, state } = makeProvider({ + installedPlugins: [ + { id: "a", enabled: true, version: "1.0.0" }, + { id: "b", enabled: false, version: "1.0.0" }, + ], + }); + const manager = new PluginPreferencesManager(provider); + await manager.updateInstalledPlugin("a", (entry) => ({ + ...entry, + version: "2.0.0", + })); + assertEquals(state.installedPlugins, [ + { id: "a", enabled: true, version: "2.0.0" }, + { id: "b", enabled: false, version: "1.0.0" }, + ]); + }); + + it("throws when the plugin is not installed", async () => { + const { provider } = makeProvider({ + installedPlugins: [{ id: "a", enabled: true }], + }); + const manager = new PluginPreferencesManager(provider); + let caught = null; + try { + await manager.updateInstalledPlugin("missing", (entry) => entry); + } catch (error) { + caught = error; + } + assert(caught instanceof Error); + assert(caught.message.includes("missing")); + }); + + it("setPluginDisabled flips enabled to false", async () => { + const { provider, state } = makeProvider({ + installedPlugins: [{ id: "a", enabled: true }], + }); + const manager = new PluginPreferencesManager(provider); + await manager.setPluginDisabled("a"); + assertEquals(state.installedPlugins, [{ id: "a", enabled: false }]); + }); + + it("setPluginEnabled flips enabled to true", async () => { + const { provider, state } = makeProvider({ + installedPlugins: [{ id: "a", enabled: false }], + }); + const manager = new PluginPreferencesManager(provider); + await manager.setPluginEnabled("a"); + assertEquals(state.installedPlugins, [{ id: "a", enabled: true }]); + }); +}); + +t.describe("plugin settings", (it) => { + it("readSettingsForPlugin returns stored settings", () => { + const { provider } = makeProvider({ + pluginSettings: { a: { color: "red" } }, + }); + const manager = new PluginPreferencesManager(provider); + assertEquals(manager.readSettingsForPlugin("a"), { color: "red" }); + assertEquals(manager.readSettingsForPlugin("missing"), undefined); + }); + + it("writeSettingsForPlugin persists and saves", async () => { + const { provider, state, saveCalls } = makeProvider(); + const manager = new PluginPreferencesManager(provider); + await manager.writeSettingsForPlugin("a", { color: "blue" }); + assertEquals(state.pluginSettings, { a: { color: "blue" } }); + assertEquals(saveCalls.length, 1); + }); + + it("clearSettingsForPlugin removes settings and saves", async () => { + const { provider, state, saveCalls } = makeProvider({ + pluginSettings: { a: { color: "blue" }, b: { count: 2 } }, + }); + const manager = new PluginPreferencesManager(provider); + await manager.clearSettingsForPlugin("a"); + assertEquals(state.pluginSettings, { b: { count: 2 } }); + assertEquals(saveCalls.length, 1); + }); +}); + +await t.run(); diff --git a/tests/unit/specs/pluginRegistry.test.js b/tests/unit/specs/pluginRegistry.test.js index 9ee6eefa..d3b643a9 100644 --- a/tests/unit/specs/pluginRegistry.test.js +++ b/tests/unit/specs/pluginRegistry.test.js @@ -1,23 +1,9 @@ import { TestSuite } from "../testSuite.js"; -import { assert, assertEquals } from "../testHelpers.js"; -import { PluginRegistry } from "/js/plugins/pluginRegistry.js"; - -function fakeFetcher(payloadsByUrl) { - const calls = []; - const fetchImpl = async (url) => { - calls.push(url); - if (!(url in payloadsByUrl)) return { ok: false, status: 404 }; - const payload = payloadsByUrl[url]; - return { - ok: true, - status: 200, - async json() { - return payload; - }, - }; - }; - return { fetchImpl, calls }; -} +import { assertEquals } from "../testHelpers.js"; +import { + RemotePluginRegistry, + LocalPluginRegistry, +} from "/js/plugins/pluginRegistry.js"; const REGISTRY_URL = "https://example.test/registry.json"; const LOCAL_INDEX_URL = "/plugins-local/index.json"; @@ -39,95 +25,124 @@ const SAMPLE = [ }, ]; -const t = new TestSuite("pluginRegistry"); +// Installs a stub for the global `fetch` (used by LocalPluginRegistry) and +// `window.fetch` (used by RemotePluginRegistry's default fetcher). Returns +// `{ calls, restore }` so tests can inspect requests and clean up. +function stubFetch(payloadsByUrl) { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + if (!(url in payloadsByUrl)) return { ok: false, status: 404 }; + const payload = payloadsByUrl[url]; + return { + ok: true, + status: 200, + async json() { + return payload; + }, + }; + }; + const originalGlobal = globalThis.fetch; + const originalWindow = globalThis.window.fetch; + globalThis.fetch = fetchImpl; + globalThis.window.fetch = fetchImpl; + const restore = () => { + globalThis.fetch = originalGlobal; + globalThis.window.fetch = originalWindow; + }; + return { calls, restore }; +} -t.describe("PluginRegistry.getPluginListings", (it) => { - it("combines local and remote listings with local marked", async () => { - const { fetchImpl } = fakeFetcher({ - [REGISTRY_URL]: SAMPLE, - [LOCAL_INDEX_URL]: [ - { id: "gamma", name: "Gamma", author: "me", description: "local" }, - ], - }); - const registry = new PluginRegistry(REGISTRY_URL, { fetchImpl }); - const listings = await registry.getPluginListings(); - assertEquals(listings.length, 3); - assertEquals(listings[0], { - id: "gamma", - name: "Gamma", - author: "me", - description: "local", - local: true, - }); - assertEquals(listings[1].id, "alpha"); - assertEquals(listings[1].local, undefined); - }); +const t = new TestSuite("pluginRegistry"); - it("local listings shadow remote listings with the same id", async () => { - const { fetchImpl } = fakeFetcher({ - [REGISTRY_URL]: SAMPLE, - [LOCAL_INDEX_URL]: [ - { id: "alpha", name: "Alpha", author: "me", description: "local" }, - ], - }); - const registry = new PluginRegistry(REGISTRY_URL, { fetchImpl }); - const listings = await registry.getPluginListings(); - assertEquals(listings.length, 2); - const alpha = listings.find((listing) => listing.id === "alpha"); - assertEquals(alpha.local, true); - }); +t.describe("RemotePluginRegistry.getListings", (it, { afterEach }) => { + let stub; + afterEach(() => stub?.restore()); - it("fetches and caches listings within TTL", async () => { - const { fetchImpl, calls } = fakeFetcher({ - [REGISTRY_URL]: SAMPLE, - [LOCAL_INDEX_URL]: [], - }); - const registry = new PluginRegistry(REGISTRY_URL, { fetchImpl }); - await registry.getPluginListings(); - await registry.getPluginListings(); - const registryCalls = calls.filter((url) => url === REGISTRY_URL); - assertEquals(registryCalls.length, 1); + it("returns the remote listings", async () => { + stub = stubFetch({ [REGISTRY_URL]: SAMPLE }); + const registry = new RemotePluginRegistry(REGISTRY_URL); + const listings = await registry.getListings(); + assertEquals(listings, SAMPLE); }); - it("force: true bypasses the cache", async () => { - const { fetchImpl, calls } = fakeFetcher({ - [REGISTRY_URL]: SAMPLE, - [LOCAL_INDEX_URL]: [], - }); - const registry = new PluginRegistry(REGISTRY_URL, { fetchImpl }); - await registry.getPluginListings(); - await registry.getPluginListings({ force: true }); - const registryCalls = calls.filter((url) => url === REGISTRY_URL); - assertEquals(registryCalls.length, 2); + it("caches listings within TTL", async () => { + stub = stubFetch({ [REGISTRY_URL]: SAMPLE }); + const registry = new RemotePluginRegistry(REGISTRY_URL); + await registry.getListings(); + await registry.getListings(); + assertEquals(stub.calls.length, 1); }); - it("tolerates a missing local index", async () => { - const { fetchImpl } = fakeFetcher({ [REGISTRY_URL]: SAMPLE }); - const registry = new PluginRegistry(REGISTRY_URL, { fetchImpl }); - const listings = await registry.getPluginListings(); - assertEquals(listings.length, 2); - assert(listings.every((listing) => !listing.local)); + it("throws when the remote responds with an error status", async () => { + stub = stubFetch({}); + const registry = new RemotePluginRegistry(REGISTRY_URL); + let caught = null; + try { + await registry.getListings(); + } catch (error) { + caught = error; + } + assertEquals(caught?.message, "registry HTTP 404"); }); }); -t.describe("PluginRegistry.getPluginListing", (it) => { +t.describe("RemotePluginRegistry.getListing", (it, { afterEach }) => { + let stub; + afterEach(() => stub?.restore()); + it("returns the listing matching the id", async () => { - const { fetchImpl } = fakeFetcher({ - [REGISTRY_URL]: SAMPLE, - [LOCAL_INDEX_URL]: [], - }); - const registry = new PluginRegistry(REGISTRY_URL, { fetchImpl }); - const listing = await registry.getPluginListing("beta"); + stub = stubFetch({ [REGISTRY_URL]: SAMPLE }); + const registry = new RemotePluginRegistry(REGISTRY_URL); + const listing = await registry.getListing("beta"); assertEquals(listing.repo, "ow/beta"); }); it("returns null when id is not in the registry", async () => { - const { fetchImpl } = fakeFetcher({ - [REGISTRY_URL]: SAMPLE, - [LOCAL_INDEX_URL]: [], - }); - const registry = new PluginRegistry(REGISTRY_URL, { fetchImpl }); - assertEquals(await registry.getPluginListing("missing"), null); + stub = stubFetch({ [REGISTRY_URL]: SAMPLE }); + const registry = new RemotePluginRegistry(REGISTRY_URL); + assertEquals(await registry.getListing("missing"), null); + }); +}); + +t.describe("LocalPluginRegistry", (it, { afterEach }) => { + let stub; + afterEach(() => stub?.restore()); + + const LOCAL_SAMPLE = [ + { id: "gamma", name: "Gamma", author: "me", description: "local" }, + ]; + + it("returns listings from the local index", async () => { + stub = stubFetch({ [LOCAL_INDEX_URL]: LOCAL_SAMPLE }); + const registry = new LocalPluginRegistry(); + assertEquals(await registry.getListings(), LOCAL_SAMPLE); + assertEquals(stub.calls, [LOCAL_INDEX_URL]); + }); + + it("getListing returns the matching listing", async () => { + stub = stubFetch({ [LOCAL_INDEX_URL]: LOCAL_SAMPLE }); + const registry = new LocalPluginRegistry(); + const listing = await registry.getListing("gamma"); + assertEquals(listing.name, "Gamma"); + }); + + it("getListing returns null when id is missing", async () => { + stub = stubFetch({ [LOCAL_INDEX_URL]: LOCAL_SAMPLE }); + const registry = new LocalPluginRegistry(); + assertEquals(await registry.getListing("missing"), null); + }); + + it("throws when the local index is not available", async () => { + stub = stubFetch({}); + const registry = new LocalPluginRegistry(); + let caught = null; + try { + await registry.getListings(); + } catch (error) { + caught = error; + } + assertEquals(caught?.message, "local registry HTTP 404"); }); }); diff --git a/tests/unit/specs/pluginService.test.js b/tests/unit/specs/pluginService.test.js new file mode 100644 index 00000000..cbc9be84 --- /dev/null +++ b/tests/unit/specs/pluginService.test.js @@ -0,0 +1,489 @@ +import { TestSuite } from "../testSuite.js"; +import { assert, assertEquals } from "../testHelpers.js"; +import { PluginService } from "/js/plugins/pluginService.js"; + +class FakePreferences { + constructor(state) { + this.state = state; + } + getInstalledPlugins() { + return this.state.installedPlugins; + } + setInstalledPlugins(plugins) { + this.state.installedPlugins = plugins; + return this; + } + getPluginSettings(pluginId) { + return this.state.pluginSettings[pluginId]; + } + setPluginSettings(pluginId, data) { + this.state.pluginSettings[pluginId] = data; + return this; + } + clearPluginSettings(pluginId) { + delete this.state.pluginSettings[pluginId]; + return this; + } +} + +function makeProvider() { + const state = { installedPlugins: [], pluginSettings: {} }; + const preferences = new FakePreferences(state); + return { + state, + provider: { + requirePreferences: () => preferences, + savePreferences: async () => {}, + }, + }; +} + +// Build a PluginService with its async-heavy dependencies replaced by +// inert fakes so we can exercise the install/update orchestration logic +// without spinning up sandbox iframes or real fetches. +function makeService({ + remoteListings = [], + localListings = null, + liveManifests = {}, +} = {}) { + const { state, provider } = makeProvider(); + const service = new PluginService(provider, null); + const loadCalls = []; + const reloadCalls = []; + const unloadCalls = []; + const reconcileCalls = []; + service.pluginBridge = { + isLoaded: () => false, + unloadPlugin: (id) => { + unloadCalls.push(id); + }, + loadPlugin: async (id, version, repo) => { + loadCalls.push({ id, version, repo }); + }, + reloadPlugin: async (id, version, repo) => { + reloadCalls.push({ id, version, repo }); + }, + loadPlugins: async (entries) => ({ + loadedPlugins: entries, + erroredPlugins: [], + }), + }; + service.remoteRegistry = { + getListing: async (id) => + remoteListings.find((listing) => listing.id === id) ?? null, + getListings: async () => remoteListings, + }; + service.localRegistry = localListings + ? { getListings: async () => localListings } + : null; + service.sourceProvider = { + getLiveManifest: async (id) => { + if (!liveManifests[id]) throw new Error(`no manifest for ${id}`); + return liveManifests[id]; + }, + getCacheUrls: async (id, version, repo) => [ + `https://cache.test/${id}/${version}/${repo}`, + ], + }; + service.pluginCache = { + reconcile: async (urls) => { + reconcileCalls.push(urls); + }, + }; + return { + service, + state, + loadCalls, + reloadCalls, + unloadCalls, + reconcileCalls, + }; +} + +const t = new TestSuite("pluginService"); + +t.describe("installPlugin", (it) => { + it("persists manifest metadata and loads the plugin", async () => { + const { service, state, loadCalls } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], + liveManifests: { + alpha: { + id: "alpha", + name: "Alpha", + version: "1.0.0", + author: "ow", + description: "the first", + }, + }, + }); + await service.installPlugin("alpha"); + assertEquals(state.installedPlugins, [ + { + id: "alpha", + name: "Alpha", + version: "1.0.0", + author: "ow", + description: "the first", + repo: "ow/alpha", + enabled: true, + }, + ]); + assertEquals(loadCalls, [ + { id: "alpha", version: "1.0.0", repo: "ow/alpha" }, + ]); + }); + + it("getPluginsInfo reflects newly installed plugin synchronously", async () => { + const { service } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], + liveManifests: { + alpha: { + id: "alpha", + name: "Alpha", + version: "1.0.0", + author: "ow", + description: "the first", + }, + }, + }); + assertEquals(service.getPluginsInfo(), []); + await service.installPlugin("alpha"); + const info = service.getPluginsInfo(); + assertEquals(info.length, 1); + assertEquals(info[0].id, "alpha"); + assertEquals(info[0].name, "Alpha"); + assertEquals(info[0].version, "1.0.0"); + assertEquals(info[0].enabled, true); + }); + + it("throws and rolls back the preference entry when load fails", async () => { + const { service, state } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], + liveManifests: { + alpha: { id: "alpha", name: "Alpha", version: "1.0.0" }, + }, + }); + service.pluginBridge.loadPlugin = async () => { + throw new Error("boom"); + }; + let caught = null; + try { + await service.installPlugin("alpha"); + } catch (error) { + caught = error; + } + assert(caught?.message.includes("boom")); + assertEquals(state.installedPlugins, []); + }); + + it("rejects when the plugin is not in the remote registry", async () => { + const { service } = makeService(); + let caught = null; + try { + await service.installPlugin("alpha"); + } catch (error) { + caught = error; + } + assert(caught?.message.includes("unknown plugin")); + }); +}); + +t.describe("updatePlugin", (it) => { + it("refreshes name/description/author/version from the live manifest", async () => { + const { service, state, reloadCalls } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], + liveManifests: { + alpha: { + id: "alpha", + name: "Alpha", + version: "1.0.0", + author: "ow", + description: "the first", + }, + }, + }); + await service.installPlugin("alpha"); + + service.sourceProvider.getLiveManifest = async () => ({ + id: "alpha", + name: "Alpha Renamed", + version: "1.1.0", + author: "ow2", + description: "new description", + }); + + const result = await service.updatePlugin("alpha"); + assertEquals(result, { updated: true, version: "1.1.0" }); + assertEquals(state.installedPlugins[0], { + id: "alpha", + name: "Alpha Renamed", + version: "1.1.0", + author: "ow2", + description: "new description", + repo: "ow/alpha", + enabled: true, + }); + assertEquals(reloadCalls, [ + { id: "alpha", version: "1.1.0", repo: "ow/alpha" }, + ]); + }); + + it("does nothing when live manifest is not newer", async () => { + const { service, state, reloadCalls } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], + liveManifests: { + alpha: { id: "alpha", name: "Alpha", version: "1.0.0" }, + }, + }); + await service.installPlugin("alpha"); + + const result = await service.updatePlugin("alpha"); + assertEquals(result, { updated: false }); + assertEquals(state.installedPlugins[0].version, "1.0.0"); + assertEquals(reloadCalls.length, 0); + }); +}); + +t.describe("loadEnabledPlugins", (it) => { + it("only loads entries marked enabled", async () => { + const { service, state } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: false }, + ]; + const loadPluginsCalls = []; + service.pluginBridge.loadPlugins = async (entries) => { + loadPluginsCalls.push(entries); + return { loadedPlugins: entries, erroredPlugins: [] }; + }; + await service.loadEnabledPlugins(); + assertEquals(loadPluginsCalls.length, 1); + assertEquals( + loadPluginsCalls[0].map((entry) => entry.id), + ["a"], + ); + }); + + it("disables plugins reported as errored by the bridge", async () => { + const { service, state } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: true }, + ]; + service.pluginBridge.loadPlugins = async () => ({ + loadedPlugins: [], + erroredPlugins: [{ pluginId: "b", error: new Error("nope") }], + }); + await service.loadEnabledPlugins(); + assertEquals( + state.installedPlugins.find((entry) => entry.id === "b").enabled, + false, + ); + assertEquals( + state.installedPlugins.find((entry) => entry.id === "a").enabled, + true, + ); + }); + + it("reconciles cache against all installed (including disabled)", async () => { + const { service, state, reconcileCalls } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: false }, + ]; + await service.loadEnabledPlugins(); + assertEquals(reconcileCalls.length, 1); + assertEquals(reconcileCalls[0], [ + "https://cache.test/a/1.0.0/ow/a", + "https://cache.test/b/1.0.0/ow/b", + ]); + }); +}); + +t.describe("uninstallPlugin", (it) => { + it("unloads, removes preference, clears settings, and reconciles", async () => { + const { service, state, unloadCalls, reconcileCalls } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: true }, + ]; + state.pluginSettings = { a: { color: "red" }, b: { color: "blue" } }; + await service.uninstallPlugin("a"); + assertEquals(unloadCalls, ["a"]); + assertEquals( + state.installedPlugins.map((entry) => entry.id), + ["b"], + ); + assertEquals(state.pluginSettings, { b: { color: "blue" } }); + // Cache should be reconciled against the remaining plugin only + assertEquals(reconcileCalls.length, 1); + assertEquals(reconcileCalls[0], ["https://cache.test/b/1.0.0/ow/b"]); + }); +}); + +t.describe("enablePlugin", (it) => { + it("flips enabled and loads the plugin", async () => { + const { service, state, loadCalls } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: false }, + ]; + await service.enablePlugin("a"); + assertEquals(state.installedPlugins[0].enabled, true); + assertEquals(loadCalls, [{ id: "a", version: "1.0.0", repo: "ow/a" }]); + }); + + it("rolls back to disabled when load fails", async () => { + const { service, state } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: false }, + ]; + service.pluginBridge.loadPlugin = async () => { + throw new Error("boom"); + }; + let caught = null; + try { + await service.enablePlugin("a"); + } catch (error) { + caught = error; + } + assert(caught?.message.includes("boom")); + assertEquals(state.installedPlugins[0].enabled, false); + }); +}); + +t.describe("reloadPlugins", (it) => { + it("reloads only enabled plugins", async () => { + const { service, state, reloadCalls } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: false }, + ]; + await service.reloadPlugins(); + assertEquals( + reloadCalls.map((call) => call.id), + ["a"], + ); + }); + + it("disables plugins that throw and re-throws the first failure", async () => { + const { service, state } = makeService(); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: true }, + ]; + service.pluginBridge.reloadPlugin = async (id) => { + if (id === "b") throw new Error("b broke"); + }; + let caught = null; + try { + await service.reloadPlugins(); + } catch (error) { + caught = error; + } + assert(caught?.message.includes("b broke")); + assertEquals( + state.installedPlugins.find((entry) => entry.id === "b").enabled, + false, + ); + assertEquals( + state.installedPlugins.find((entry) => entry.id === "a").enabled, + true, + ); + }); +}); + +t.describe("checkForUpdates", (it) => { + it("populates _availableUpdates with plugins whose live version is newer", async () => { + const { service, state } = makeService({ + liveManifests: { + a: { id: "a", name: "A", version: "2.0.0" }, + b: { id: "b", name: "B", version: "1.0.0" }, + }, + }); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: true }, + ]; + const updates = await service.checkForUpdates(); + assertEquals([...updates.entries()], [["a", "2.0.0"]]); + assertEquals(service.getAvailableUpdates(), updates); + }); + + it("skips plugins whose live manifest fails to fetch", async () => { + const { service, state } = makeService({ + liveManifests: { + a: { id: "a", name: "A", version: "2.0.0" }, + // b intentionally missing — getLiveManifest will throw + }, + }); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: true }, + ]; + const updates = await service.checkForUpdates(); + assertEquals([...updates.keys()], ["a"]); + }); +}); + +t.describe("updateAllPlugins", (it) => { + it("returns empty buckets when there are no available updates", async () => { + const { service } = makeService(); + const result = await service.updateAllPlugins(); + assertEquals(result, { updated: [], failed: [] }); + }); + + it("partitions results into updated and failed buckets", async () => { + const { service, state } = makeService({ + liveManifests: { + a: { id: "a", name: "A", version: "2.0.0" }, + b: { id: "b", name: "B", version: "2.0.0" }, + }, + }); + state.installedPlugins = [ + { id: "a", version: "1.0.0", repo: "ow/a", enabled: true }, + { id: "b", version: "1.0.0", repo: "ow/b", enabled: true }, + ]; + await service.checkForUpdates(); + // Make b's reload fail; a should still update successfully. + service.pluginBridge.reloadPlugin = async (id) => { + if (id === "b") throw new Error("reload failed"); + }; + const result = await service.updateAllPlugins(); + assertEquals(result.updated, ["a"]); + assertEquals(result.failed, ["b"]); + }); +}); + +t.describe("listRegistryPlugins", (it) => { + it("merges remote + local listings and marks installed entries", async () => { + const { service, state } = makeService({ + remoteListings: [ + { id: "alpha", repo: "ow/alpha", name: "Alpha" }, + { id: "beta", repo: "ow/beta", name: "Beta" }, + ], + localListings: [{ id: "gamma__LOCAL", name: "Gamma" }], + }); + state.installedPlugins = [ + { id: "alpha", version: "1.0.0", repo: "ow/alpha", enabled: true }, + ]; + const listings = await service.listRegistryPlugins(); + assertEquals(listings.length, 3); + const byId = Object.fromEntries( + listings.map((listing) => [listing.id, listing]), + ); + assertEquals(byId.alpha.installed, true); + assertEquals(byId.beta.installed, false); + assertEquals(byId.gamma__LOCAL.installed, false); + }); + + it("returns only remote listings when localRegistry is absent", async () => { + const { service } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha", name: "Alpha" }], + }); + const listings = await service.listRegistryPlugins(); + assertEquals(listings.length, 1); + assertEquals(listings[0].id, "alpha"); + }); +}); + +await t.run(); diff --git a/tests/unit/specs/sourceProvider.test.js b/tests/unit/specs/sourceProvider.test.js index b93735e1..d81d441b 100644 --- a/tests/unit/specs/sourceProvider.test.js +++ b/tests/unit/specs/sourceProvider.test.js @@ -15,148 +15,187 @@ function jsonResponse(body, { ok = true, status = 200 } = {}) { }; } -function fakeRegistry(listingsById) { +// Installs a stub for `fetch` (used by SourceProvider for local plugins) on +// both globalThis and window. Returns `{ calls, restore }`. +function stubFetch(handler) { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, options }); + return handler(url, options); + }; + const originalGlobal = globalThis.fetch; + const originalWindow = globalThis.window.fetch; + globalThis.fetch = fetchImpl; + globalThis.window.fetch = fetchImpl; + return { + calls, + restore() { + globalThis.fetch = originalGlobal; + globalThis.window.fetch = originalWindow; + }, + }; +} + +function fakePluginCache(handler) { + const calls = []; return { - async getPluginListing(id) { - return listingsById[id] ?? null; + calls, + async fetch(url) { + calls.push(url); + return handler(url); }, }; } const t = new TestSuite("sourceProviders"); -t.describe("SourceProvider with local listings", (it) => { - it("fetches local manifest from /plugins-local/", async () => { - let fetchedUrl = null; - const fetchImpl = async (url) => { - fetchedUrl = url; - return jsonResponse({ id: "alpha", name: "A", version: "1.0.0" }); - }; - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", name: "A", local: true } }), - null, - { fetchImpl }, +t.describe("SourceProvider with local plugins", (it, { afterEach }) => { + let stub; + afterEach(() => stub?.restore()); + + it("fetches local manifest from /plugins-local/ and appends __LOCAL", async () => { + stub = stubFetch(async () => + jsonResponse({ id: "alpha", name: "Alpha", version: "1.0.0" }), ); - const manifest = await provider.getManifest("alpha"); - assertEquals(fetchedUrl, "/plugins-local/alpha/manifest.json"); + const provider = new SourceProvider(null); + const manifest = await provider.getManifest("alpha__LOCAL"); + assertEquals( + stub.calls[0].url, + "/plugins-local/alpha__LOCAL/manifest.json", + ); + assertEquals(manifest.id, "alpha__LOCAL"); assertEquals(manifest.version, "1.0.0"); }); it("fetches local source from /plugins-local/", async () => { - let fetchedUrl = null; - const fetchImpl = async (url) => { - fetchedUrl = url; - return jsonResponse("alert(1)"); - }; - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", name: "A", local: true } }), - null, - { fetchImpl }, - ); - await provider.getSource("alpha"); - assertEquals(fetchedUrl, "/plugins-local/alpha/main.js"); + stub = stubFetch(async () => jsonResponse("alert(1)")); + const provider = new SourceProvider(null); + const source = await provider.getSource("alpha__LOCAL"); + assertEquals(stub.calls[0].url, "/plugins-local/alpha__LOCAL/main.js"); + assertEquals(source, "alert(1)"); }); - it("rejects manifest with mismatched id", async () => { - const fetchImpl = async () => - jsonResponse({ id: "different", name: "A", version: "1.0.0" }); - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", name: "A", local: true } }), - null, - { fetchImpl }, + it("rejects local manifest with mismatched id", async () => { + stub = stubFetch(async () => + jsonResponse({ id: "different", name: "A", version: "1.0.0" }), ); - let threw = false; + const provider = new SourceProvider(null); + let caught = null; try { - await provider.getManifest("alpha"); + await provider.getManifest("alpha__LOCAL"); + } catch (error) { + caught = error; + } + assert(caught?.message.includes("does not match")); + }); + + it("throws when local manifest is missing required fields", async () => { + stub = stubFetch(async () => jsonResponse({ id: "alpha", name: "A" })); + const provider = new SourceProvider(null); + let caught = null; + try { + await provider.getManifest("alpha__LOCAL"); + } catch (error) { + caught = error; + } + assert(caught?.message.includes("version")); + }); + + it("throws when local manifest fetch fails", async () => { + stub = stubFetch(async () => ({ ok: false, status: 404 })); + const provider = new SourceProvider(null); + let caught = null; + try { + await provider.getManifest("alpha__LOCAL"); } catch (error) { - threw = true; - assert(error.message.includes("does not match")); + caught = error; } - assert(threw); + assertEquals(caught?.message, "HTTP 404"); }); it("getCacheUrls returns empty for local plugins", async () => { - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", name: "A", local: true } }), - null, + const provider = new SourceProvider(null); + assertEquals(await provider.getCacheUrls("alpha__LOCAL"), []); + }); + + it("getLiveManifest delegates to getManifest for local plugins", async () => { + stub = stubFetch(async () => + jsonResponse({ id: "alpha", name: "Alpha", version: "9.9.9" }), ); - assertEquals(await provider.getCacheUrls("alpha"), []); + const provider = new SourceProvider(null); + const manifest = await provider.getLiveManifest("alpha__LOCAL"); + assertEquals(manifest.version, "9.9.9"); + assertEquals(manifest.id, "alpha__LOCAL"); }); }); -t.describe("SourceProvider with remote listings", (it) => { +t.describe("SourceProvider with remote plugins", (it) => { it("fetches manifest from versioned release URL via plugin cache", async () => { - let fetchedUrl = null; - const pluginCache = { - async fetch(url) { - fetchedUrl = url; - return jsonResponse({ id: "alpha", name: "A", version: "1.0.0" }); - }, - }; - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", repo: "ow/alpha" } }), - pluginCache, + const pluginCache = fakePluginCache(async () => + jsonResponse({ id: "alpha", name: "A", version: "1.0.0" }), ); - const manifest = await provider.getManifest("alpha", "1.0.0"); + const provider = new SourceProvider(pluginCache); + const manifest = await provider.getManifest("alpha", "1.0.0", "ow/alpha"); assertEquals( - fetchedUrl, + pluginCache.calls[0], "https://raw.githubusercontent.com/ow/alpha/1.0.0/manifest.json", ); assertEquals(manifest.id, "alpha"); }); - it("uses the version that was passed in", async () => { - let fetchedUrl = null; - const pluginCache = { - async fetch(url) { - fetchedUrl = url; - return jsonResponse("alert(1)"); + it("fetches source from the version that was passed in", async () => { + const pluginCache = fakePluginCache(async () => ({ + ok: true, + status: 200, + async text() { + return "alert(1)"; }, - }; - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", repo: "ow/alpha" } }), - pluginCache, - ); - await provider.getSource("alpha", "2.5.0"); + })); + const provider = new SourceProvider(pluginCache); + const source = await provider.getSource("alpha", "2.5.0", "ow/alpha"); assertEquals( - fetchedUrl, + pluginCache.calls[0], "https://raw.githubusercontent.com/ow/alpha/2.5.0/main.js", ); + assertEquals(source, "alert(1)"); }); - it("throws when version is omitted for a remote plugin", async () => { - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", repo: "ow/alpha" } }), - { async fetch() {} }, - ); - let threw = false; + it("throws when version or repo is omitted for a remote plugin", async () => { + const provider = new SourceProvider(fakePluginCache(async () => null)); + let caught = null; try { await provider.getManifest("alpha"); } catch (error) { - threw = true; - assert(error.message.includes("version required")); + caught = error; } - assert(threw); + assert(caught?.message.includes("Version and repo are required")); + + caught = null; + try { + await provider.getSource("alpha", "1.0.0"); + } catch (error) { + caught = error; + } + assert(caught?.message.includes("Version and repo are required")); }); - it("throws when plugin is not in registry", async () => { - const provider = new SourceProvider(fakeRegistry({}), null); - let threw = false; + it("rejects remote manifest with mismatched id", async () => { + const pluginCache = fakePluginCache(async () => + jsonResponse({ id: "different", name: "A", version: "1.0.0" }), + ); + const provider = new SourceProvider(pluginCache); + let caught = null; try { - await provider.getManifest("missing"); + await provider.getManifest("alpha", "1.0.0", "ow/alpha"); } catch (error) { - threw = true; - assert(error.message.includes("not in registry")); + caught = error; } - assert(threw); + assert(caught?.message.includes("does not match")); }); it("getCacheUrls returns both manifest and main.js URLs", async () => { - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", repo: "ow/alpha" } }), - null, - ); - const urls = await provider.getCacheUrls("alpha", "1.2.3"); + const provider = new SourceProvider(null); + const urls = await provider.getCacheUrls("alpha", "1.2.3", "ow/alpha"); assertEquals(urls, [ "https://raw.githubusercontent.com/ow/alpha/1.2.3/manifest.json", "https://raw.githubusercontent.com/ow/alpha/1.2.3/main.js", @@ -164,24 +203,4 @@ t.describe("SourceProvider with remote listings", (it) => { }); }); -t.describe("SourceProvider.getLiveManifest", (it) => { - it("hits raw.githubusercontent.com at main", async () => { - const liveUrl = - "https://raw.githubusercontent.com/ow/alpha/main/manifest.json"; - let fetchedUrl = null; - const fetchImpl = async (url) => { - fetchedUrl = url; - return jsonResponse({ id: "alpha", name: "Alpha", version: "9.9.9" }); - }; - const provider = new SourceProvider( - fakeRegistry({ alpha: { id: "alpha", repo: "ow/alpha" } }), - null, - { fetchImpl }, - ); - const manifest = await provider.getLiveManifest("alpha"); - assertEquals(manifest.version, "9.9.9"); - assertEquals(fetchedUrl, liveUrl); - }); -}); - await t.run();