Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Allow plugins to be viewed while logged out

Grace Kind (Jul 13, 2026, 11:00 PM -0500) a07fbbdd 6c2a362a

+272 -114
+20 -20
src/css/style.css
··· 9394 9394 max-width: 32em; 9395 9395 } 9396 9396 9397 - #settings-community-plugins-view .error-state, 9397 + #community-plugins-view .error-state, 9398 9398 #settings-plugins-view .error-state { 9399 9399 padding: 32px 20px; 9400 9400 } ··· 9495 9495 } 9496 9496 9497 9497 /* Community plugin rows are whole-row navigation links */ 9498 - #settings-community-plugins-view .plugin-list-item { 9498 + #community-plugins-view .plugin-list-item { 9499 9499 padding: 0; 9500 9500 } 9501 9501 ··· 9587 9587 padding: 16px; 9588 9588 } 9589 9589 9590 - #settings-community-plugin-listing-view rendered-markdown { 9590 + #community-plugin-listing-view rendered-markdown { 9591 9591 display: block; 9592 9592 font-size: 15px; 9593 9593 line-height: 1.6; ··· 9595 9595 word-break: break-word; 9596 9596 } 9597 9597 9598 - #settings-community-plugin-listing-view rendered-markdown h1, 9599 - #settings-community-plugin-listing-view rendered-markdown h2, 9600 - #settings-community-plugin-listing-view rendered-markdown h3, 9601 - #settings-community-plugin-listing-view rendered-markdown h4 { 9598 + #community-plugin-listing-view rendered-markdown h1, 9599 + #community-plugin-listing-view rendered-markdown h2, 9600 + #community-plugin-listing-view rendered-markdown h3, 9601 + #community-plugin-listing-view rendered-markdown h4 { 9602 9602 line-height: 1.25; 9603 9603 margin: 24px 0 12px; 9604 9604 } 9605 9605 9606 - #settings-community-plugin-listing-view rendered-markdown h1 { 9606 + #community-plugin-listing-view rendered-markdown h1 { 9607 9607 font-size: 24px; 9608 9608 } 9609 9609 9610 - #settings-community-plugin-listing-view rendered-markdown h2 { 9610 + #community-plugin-listing-view rendered-markdown h2 { 9611 9611 font-size: 20px; 9612 9612 } 9613 9613 9614 - #settings-community-plugin-listing-view rendered-markdown h3 { 9614 + #community-plugin-listing-view rendered-markdown h3 { 9615 9615 font-size: 17px; 9616 9616 } 9617 9617 9618 - #settings-community-plugin-listing-view 9618 + #community-plugin-listing-view 9619 9619 rendered-markdown 9620 9620 :is(h1, h2, h3, h4):first-child { 9621 9621 margin-top: 0; 9622 9622 } 9623 9623 9624 - #settings-community-plugin-listing-view rendered-markdown p, 9625 - #settings-community-plugin-listing-view rendered-markdown ul, 9626 - #settings-community-plugin-listing-view rendered-markdown ol { 9624 + #community-plugin-listing-view rendered-markdown p, 9625 + #community-plugin-listing-view rendered-markdown ul, 9626 + #community-plugin-listing-view rendered-markdown ol { 9627 9627 margin: 12px 0; 9628 9628 } 9629 9629 ··· 9654 9654 padding-left: 0; 9655 9655 } 9656 9656 9657 - #settings-community-plugin-listing-view rendered-markdown a { 9657 + #community-plugin-listing-view rendered-markdown a { 9658 9658 color: var(--primary-button-color); 9659 9659 } 9660 9660 9661 - #settings-community-plugin-listing-view rendered-markdown img { 9661 + #community-plugin-listing-view rendered-markdown img { 9662 9662 max-width: 100%; 9663 9663 height: auto; 9664 9664 } 9665 9665 9666 - #settings-community-plugin-listing-view rendered-markdown code { 9666 + #community-plugin-listing-view rendered-markdown code { 9667 9667 font-family: var(--mono-font, monospace); 9668 9668 font-size: 0.9em; 9669 9669 background: var(--secondary-button-color); ··· 9671 9671 border-radius: var(--tag-border-radius); 9672 9672 } 9673 9673 9674 - #settings-community-plugin-listing-view rendered-markdown pre { 9674 + #community-plugin-listing-view rendered-markdown pre { 9675 9675 background: var(--secondary-button-color); 9676 9676 padding: 12px; 9677 9677 border-radius: var(--callout-border-radius); 9678 9678 overflow-x: auto; 9679 9679 } 9680 9680 9681 - #settings-community-plugin-listing-view rendered-markdown pre code { 9681 + #community-plugin-listing-view rendered-markdown pre code { 9682 9682 background: none; 9683 9683 padding: 0; 9684 9684 } 9685 9685 9686 - #settings-community-plugin-listing-view rendered-markdown blockquote { 9686 + #community-plugin-listing-view rendered-markdown blockquote { 9687 9687 margin: 12px 0; 9688 9688 padding-left: 12px; 9689 9689 border-left: 3px solid var(--post-border-color);
+14 -13
src/index.html
··· 143 143 import settingsAdvancedView from "/js/views/settings/advanced.view.js"; 144 144 import settingsPluginsView from "/js/views/settings/plugins.view.js"; 145 145 import settingsPluginDetailView from "/js/views/settings/pluginDetail.view.js"; 146 - import settingsCommunityPluginsView from "/js/views/settings/communityPlugins.view.js"; 147 - import settingsCommunityPluginListingView from "/js/views/settings/communityPluginListing.view.js"; 146 + import communityPluginsView from "/js/views/communityPlugins.view.js"; 147 + import communityPluginListingView from "/js/views/communityPluginListing.view.js"; 148 148 import feedDetailView from "/js/views/feedDetail.view.js"; 149 149 import listDetailView from "/js/views/listDetail.view.js"; 150 150 import bookmarksView from "/js/views/bookmarks.view.js"; ··· 241 241 // This will save us from needing to resolve handles when navigating between pages. 242 242 setUpIdentityPrecaching(dataLayer, identityResolver); 243 243 244 - // Preload the current user for layout + views 244 + // Preload the current user for layout 245 245 if (session) { 246 246 dataLayer.declarative.ensureCurrentUser().catch((error) => { 247 247 console.warn("Error preloading current user:", error); ··· 435 435 settingsRouteOptions, 436 436 ); 437 437 router.addRoute( 438 - "/settings/plugins/community", 439 - () => settingsCommunityPluginsView, 438 + "/settings/plugins/:pluginId", 439 + () => settingsPluginDetailView, 440 440 settingsRouteOptions, 441 441 ); 442 + router.addRoute("/plugins/community", () => communityPluginsView); 442 443 router.addRoute( 443 - "/settings/plugins/community/:pluginId", 444 - () => settingsCommunityPluginListingView, 445 - settingsRouteOptions, 444 + "/plugins/community/:pluginId", 445 + () => communityPluginListingView, 446 446 ); 447 - router.addRoute( 448 - "/settings/plugins/:pluginId", 449 - () => settingsPluginDetailView, 450 - settingsRouteOptions, 451 - ); 447 + router.addRedirects({ 448 + // Old community plugin URLs 449 + "/settings/plugins/community": () => "/plugins/community", 450 + "/settings/plugins/community/:pluginId": (params) => 451 + `/plugins/community/${encodeURIComponent(params.pluginId)}`, 452 + }); 452 453 router.setNotFoundView(() => notFoundView); 453 454 454 455 router.renderRoute(({ view, params, container, layout }) => {
+24
src/js/router.js
··· 63 63 constructor() { 64 64 super(); 65 65 this.routes = {}; 66 + this.redirects = {}; 66 67 this.notFoundView = () => {}; 67 68 this.notFoundOptions = {}; 68 69 this.renderFunc = () => {}; ··· 102 103 for (const path of [].concat(paths)) { 103 104 this.routes[path] = { viewGetter, options }; 104 105 } 106 + } 107 + 108 + addRedirects(redirects) { 109 + Object.assign(this.redirects, redirects); 110 + } 111 + 112 + matchRedirect(pathname) { 113 + for (const [pattern, redirect] of Object.entries(this.redirects)) { 114 + const params = Router.matchPath(pathname, pattern); 115 + if (params) { 116 + return redirect(params); 117 + } 118 + } 119 + return null; 105 120 } 106 121 107 122 renderRoute(renderFunc) { ··· 188 203 } 189 204 190 205 async load(path, { isBack = false } = {}) { 206 + const [pathnameForRedirect, query] = path.split("?"); 207 + const redirectTarget = this.matchRedirect(pathnameForRedirect); 208 + if (redirectTarget !== null) { 209 + const redirectPath = query 210 + ? `${redirectTarget}?${query}` 211 + : redirectTarget; 212 + window.history.replaceState(window.history.state, "", redirectPath); 213 + return this.load(redirectPath, { isBack }); 214 + } 191 215 // Save the scroll position of the page we're leaving before swapping it out 192 216 if (this.currentPath != null) { 193 217 this.scrollStates.set(this.currentPath, window.scrollY);
+36 -35
src/js/views/settings/communityPluginListing.view.js src/js/views/communityPluginListing.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect, bindToPage } from "/js/router.js"; 3 + import { pageEffect } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 - import { auth } from "/js/auth.js"; 5 + import { linkToLogin } from "/js/navigation.js"; 6 6 import { showToast } from "/js/toasts.js"; 7 7 import { confirmModal } from "/js/modals/confirm.modal.js"; 8 8 import { Signal, ReactiveStore } from "/js/signals.js"; 9 9 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 10 10 import "/js/components/rendered-markdown.js"; 11 11 12 - class SettingsCommunityPluginListingView extends View { 13 - async render({ root, router, layout, params, context: { pluginService } }) { 14 - await auth.requireAuth(); 15 - 12 + class CommunityPluginListingView extends View { 13 + async render({ root, params, context: { pluginService, isAuthenticated } }) { 16 14 const { pluginId } = params; 17 15 const isLocal = pluginId.endsWith("__LOCAL"); 18 16 19 - const state = new ReactiveStore("settingsCommunityPluginListingView"); 17 + const state = new ReactiveStore("communityPluginListingView"); 20 18 state.$loadError = new Signal.State(null); 21 19 state.$version = new Signal.State(null); 22 20 state.$readme = new Signal.State(null); ··· 105 103 state.$pendingAction.set(null); 106 104 } 107 105 108 - bindToPage(root, layout, "active-nav-click", (event) => { 109 - event.preventDefault(); 110 - router.go("/settings"); 111 - }); 106 + function installButtonTemplate({ listing, pendingAction }) { 107 + const installButtonClass = listing.installed 108 + ? "plugin-install-button rounded-button" 109 + : "plugin-install-button rounded-button rounded-button-primary"; 110 + return html`<button 111 + class=${installButtonClass} 112 + data-testid="plugin-listing-install-button" 113 + ?disabled=${pendingAction !== null} 114 + @click=${() => toggleInstall(listing)} 115 + > 116 + ${pendingAction 117 + ? html`${pendingAction === "uninstall" 118 + ? "Uninstalling" 119 + : "Installing"} 120 + <div class="loading-spinner" data-testid="loading-spinner"></div>` 121 + : listing.installed 122 + ? "Uninstall" 123 + : "Install"} 124 + </button>`; 125 + } 112 126 113 127 pageEffect(root, () => { 114 128 const listing = state.$listing.get(); ··· 117 131 const readme = state.$readme.get(); 118 132 const loading = state.$loading.get(); 119 133 const pendingAction = state.$pendingAction.get(); 120 - const installButtonClass = listing?.installed 121 - ? "plugin-install-button rounded-button" 122 - : "plugin-install-button rounded-button rounded-button-primary"; 123 134 render( 124 - html`<div id="settings-community-plugin-listing-view"> 135 + html`<div id="community-plugin-listing-view"> 125 136 ${headerTemplate({ 126 137 title: "Community plugins", 127 - backButtonFallbackRoute: "/settings/plugins/community", 138 + backButtonFallbackRoute: "/plugins/community", 128 139 })} 129 140 <main> 130 141 ${(() => { ··· 197 208 </p>` 198 209 : ""} 199 210 <div class="plugin-listing-actions"> 200 - <button 201 - class=${installButtonClass} 202 - data-testid="plugin-listing-install-button" 203 - ?disabled=${pendingAction !== null} 204 - @click=${() => toggleInstall(listing)} 205 - > 206 - ${pendingAction 207 - ? html`${pendingAction === "uninstall" 208 - ? "Uninstalling" 209 - : "Installing"} 210 - <div 211 - class="loading-spinner" 212 - data-testid="loading-spinner" 213 - ></div>` 214 - : listing.installed 215 - ? "Uninstall" 216 - : "Install"} 217 - </button> 211 + ${isAuthenticated 212 + ? installButtonTemplate({ listing, pendingAction }) 213 + : html`<a 214 + class="plugin-install-button rounded-button rounded-button-primary" 215 + data-testid="plugin-listing-login-button" 216 + href=${linkToLogin()} 217 + >Sign in to install</a 218 + >`} 218 219 </div> 219 220 </div> 220 221 ${readme ··· 244 245 } 245 246 } 246 247 247 - export default new SettingsCommunityPluginListingView(); 248 + export default new CommunityPluginListingView();
+10 -16
src/js/views/settings/communityPlugins.view.js src/js/views/communityPlugins.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect, bindToPage } from "/js/router.js"; 3 + import { pageEffect } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 - import { auth } from "/js/auth.js"; 6 5 import { chevronRightIconTemplate } from "/js/templates/icons/chevronRight.template.js"; 7 6 import { Signal, ReactiveStore } from "/js/signals.js"; 8 7 9 - class SettingsCommunityPluginsView extends View { 10 - async render({ root, router, layout, context: { pluginService } }) { 11 - await auth.requireAuth(); 12 - 13 - const state = new ReactiveStore("settingsCommunityPluginsView"); 8 + class CommunityPluginsView extends View { 9 + async render({ root, context: { pluginService, isAuthenticated } }) { 10 + const state = new ReactiveStore("communityPluginsView"); 14 11 state.$error = new Signal.State(null); 15 12 16 13 async function loadListings() { ··· 23 20 } 24 21 } 25 22 26 - bindToPage(root, layout, "active-nav-click", (event) => { 27 - event.preventDefault(); 28 - router.go("/settings"); 29 - }); 30 - 31 23 pageEffect(root, () => { 32 24 const error = state.$error.get(); 33 25 const listings = pluginService.$registryListings.get(); 34 26 render( 35 - html`<div id="settings-community-plugins-view"> 27 + html`<div id="community-plugins-view"> 36 28 ${headerTemplate({ 37 29 title: "Community plugins", 38 - backButtonFallbackRoute: "/settings/plugins", 30 + backButtonFallbackRoute: isAuthenticated 31 + ? "/settings/plugins" 32 + : "/", 39 33 })} 40 34 <main> 41 35 ${error ··· 68 62 <li class="plugin-list-item"> 69 63 <a 70 64 class="plugin-list-item-link" 71 - href="/settings/plugins/community/${listing.id}" 65 + href="/plugins/community/${listing.id}" 72 66 > 73 67 <div class="plugin-list-item-info"> 74 68 <div class="plugin-list-item-name"> ··· 122 116 } 123 117 } 124 118 125 - export default new SettingsCommunityPluginsView(); 119 + export default new CommunityPluginsView();
+1 -4
src/js/views/settings/plugins.view.js
··· 166 166 backButtonFallbackRoute: "/settings", 167 167 })} 168 168 <main> 169 - <a 170 - class="community-plugins-link" 171 - href="/settings/plugins/community" 172 - > 169 + <a class="community-plugins-link" href="/plugins/community"> 173 170 <span class="community-plugins-link-icon" 174 171 >${globeIconTemplate()}</span 175 172 >
+3 -3
tests/e2e/specs/flows/installRemotePlugin.test.js
··· 21 21 await login(page); 22 22 23 23 // Open the plugin's detail page from the community view, then install. 24 - await page.goto("/settings/plugins/community"); 25 - const community = page.locator("#settings-community-plugins-view"); 24 + await page.goto("/plugins/community"); 25 + const community = page.locator("#community-plugins-view"); 26 26 const installItem = community.locator(".plugin-list-item", { 27 27 hasText: "Remote Themes", 28 28 }); 29 29 await expect(installItem).toBeVisible({ timeout: 10000 }); 30 30 await installItem.locator(".plugin-list-item-link").click(); 31 31 32 - const listing = page.locator("#settings-community-plugin-listing-view"); 32 + const listing = page.locator("#community-plugin-listing-view"); 33 33 const installButton = listing.locator( 34 34 '[data-testid="plugin-listing-install-button"]', 35 35 );
+52 -11
tests/e2e/specs/views/settings/communityPluginListing.view.test.js tests/e2e/specs/views/communityPluginListing.view.test.js
··· 1 - import { test, expect } from "../../../base.js"; 2 - import { login } from "../../../helpers.js"; 3 - import { MockServer } from "../../../mockServer.js"; 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 4 5 5 const REMOTE_ID = "remote-themes"; 6 6 const REGISTRY_ENTRY = { ··· 12 12 }; 13 13 14 14 function detailUrl(id = REMOTE_ID) { 15 - return `/settings/plugins/community/${id}`; 15 + return `/plugins/community/${id}`; 16 16 } 17 17 18 - test.describe("Settings community plugin listing view", () => { 18 + test.describe("Community plugin listing view", () => { 19 19 test("shows the plugin's header info and renders its README", async ({ 20 20 page, 21 21 }) => { ··· 26 26 await login(page); 27 27 28 28 await page.goto(detailUrl()); 29 - const view = page.locator("#settings-community-plugin-listing-view"); 29 + const view = page.locator("#community-plugin-listing-view"); 30 30 await expect( 31 31 view.locator('[data-testid="plugin-listing-name"]'), 32 32 ).toHaveText("Remote Themes", { timeout: 10000 }); ··· 58 58 await login(page); 59 59 60 60 await page.goto(detailUrl()); 61 - const view = page.locator("#settings-community-plugin-listing-view"); 61 + const view = page.locator("#community-plugin-listing-view"); 62 62 await expect( 63 63 view.locator('[data-testid="plugin-listing-header"]'), 64 64 ).toBeVisible({ timeout: 10000 }); ··· 76 76 await login(page); 77 77 78 78 await page.goto(detailUrl()); 79 - const view = page.locator("#settings-community-plugin-listing-view"); 79 + const view = page.locator("#community-plugin-listing-view"); 80 80 const button = view.locator( 81 81 '[data-testid="plugin-listing-install-button"]', 82 82 ); ··· 104 104 await login(page); 105 105 106 106 await page.goto(detailUrl()); 107 - const view = page.locator("#settings-community-plugin-listing-view"); 107 + const view = page.locator("#community-plugin-listing-view"); 108 108 const button = view.locator( 109 109 '[data-testid="plugin-listing-install-button"]', 110 110 ); ··· 135 135 await login(page); 136 136 137 137 await page.goto(detailUrl()); 138 - const view = page.locator("#settings-community-plugin-listing-view"); 138 + const view = page.locator("#community-plugin-listing-view"); 139 139 const button = view.locator( 140 140 '[data-testid="plugin-listing-install-button"]', 141 141 ); ··· 159 159 await login(page); 160 160 161 161 await page.goto(detailUrl("does-not-exist")); 162 - const view = page.locator("#settings-community-plugin-listing-view"); 162 + const view = page.locator("#community-plugin-listing-view"); 163 163 await expect( 164 164 view.locator('[data-testid="plugin-listing-not-found"]'), 165 165 ).toBeVisible({ timeout: 10000 }); 166 + }); 167 + 168 + test("shows a sign-in button instead of Install when logged out", async ({ 169 + page, 170 + }) => { 171 + const mockServer = new MockServer(); 172 + mockServer.registryEntries = [REGISTRY_ENTRY]; 173 + await mockServer.setup(page); 174 + 175 + await page.goto(detailUrl()); 176 + const view = page.locator("#community-plugin-listing-view"); 177 + const loginButton = view.locator( 178 + '[data-testid="plugin-listing-login-button"]', 179 + ); 180 + await expect(loginButton).toBeVisible({ timeout: 10000 }); 181 + await expect( 182 + view.locator('[data-testid="plugin-listing-install-button"]'), 183 + ).toHaveCount(0); 184 + 185 + await loginButton.click(); 186 + await expect(page).toHaveURL( 187 + new RegExp(`/login\\?returnTo=${encodeURIComponent(detailUrl())}$`), 188 + ); 189 + }); 190 + 191 + test("redirects the old settings URL to the new listing URL", async ({ 192 + page, 193 + }) => { 194 + const mockServer = new MockServer(); 195 + mockServer.registryEntries = [REGISTRY_ENTRY]; 196 + await mockServer.setup(page); 197 + await login(page); 198 + 199 + await page.goto(`/settings/plugins/community/${REMOTE_ID}`); 200 + await expect(page).toHaveURL( 201 + new RegExp(`/plugins/community/${REMOTE_ID}$`), 202 + ); 203 + const view = page.locator("#community-plugin-listing-view"); 204 + await expect( 205 + view.locator('[data-testid="plugin-listing-name"]'), 206 + ).toHaveText("Remote Themes", { timeout: 10000 }); 166 207 }); 167 208 });
+44 -12
tests/e2e/specs/views/settings/communityPlugins.view.test.js tests/e2e/specs/views/communityPlugins.view.test.js
··· 1 - import { test, expect } from "../../../base.js"; 2 - import { login } from "../../../helpers.js"; 3 - import { MockServer } from "../../../mockServer.js"; 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 4 5 5 const REMOTE_ID = "remote-themes"; 6 6 const REGISTRY_ENTRY = { ··· 11 11 description: "Adds extra themes", 12 12 }; 13 13 14 - test.describe("Settings community plugins view", () => { 14 + test.describe("Community plugins view", () => { 15 15 test("lists every registry plugin (local and remote)", async ({ page }) => { 16 16 const mockServer = new MockServer(); 17 17 mockServer.registryEntries = [REGISTRY_ENTRY]; 18 18 await mockServer.setup(page); 19 19 await login(page); 20 20 21 - await page.goto("/settings/plugins/community"); 22 - const view = page.locator("#settings-community-plugins-view"); 21 + await page.goto("/plugins/community"); 22 + const view = page.locator("#community-plugins-view"); 23 23 await expect(view.locator('[data-testid="header-title"]')).toContainText( 24 24 "Community plugins", 25 25 { timeout: 10000 }, ··· 47 47 await mockServer.setup(page); 48 48 await login(page); 49 49 50 - await page.goto("/settings/plugins/community"); 51 - const view = page.locator("#settings-community-plugins-view"); 50 + await page.goto("/plugins/community"); 51 + const view = page.locator("#community-plugins-view"); 52 52 const installedItem = view.locator(".plugin-list-item", { 53 53 hasText: "Remote Themes", 54 54 }); ··· 71 71 await mockServer.setup(page); 72 72 await login(page); 73 73 74 - await page.goto("/settings/plugins/community"); 75 - const view = page.locator("#settings-community-plugins-view"); 74 + await page.goto("/plugins/community"); 75 + const view = page.locator("#community-plugins-view"); 76 76 const item = view.locator(".plugin-list-item", { 77 77 hasText: "Remote Themes", 78 78 }); ··· 81 81 await item.locator(".plugin-list-item-link").click(); 82 82 83 83 await expect(page).toHaveURL( 84 - new RegExp(`/settings/plugins/community/${REMOTE_ID}$`), 84 + new RegExp(`/plugins/community/${REMOTE_ID}$`), 85 85 ); 86 - const detail = page.locator("#settings-community-plugin-listing-view"); 86 + const detail = page.locator("#community-plugin-listing-view"); 87 87 await expect( 88 88 detail.locator('[data-testid="plugin-listing-name"]'), 89 89 ).toHaveText("Remote Themes", { timeout: 10000 }); 90 + }); 91 + 92 + test("renders for logged-out users without installed badges", async ({ 93 + page, 94 + }) => { 95 + const mockServer = new MockServer(); 96 + mockServer.registryEntries = [REGISTRY_ENTRY]; 97 + await mockServer.setup(page); 98 + 99 + await page.goto("/plugins/community"); 100 + const view = page.locator("#community-plugins-view"); 101 + await expect( 102 + view.locator(".plugin-list-item", { hasText: "Remote Themes" }), 103 + ).toBeVisible({ timeout: 10000 }); 104 + await expect( 105 + view.locator('[data-testid="plugin-installed-badge"]'), 106 + ).toHaveCount(0); 107 + }); 108 + 109 + test("redirects the old settings URL to /plugins/community", async ({ 110 + page, 111 + }) => { 112 + const mockServer = new MockServer(); 113 + mockServer.registryEntries = [REGISTRY_ENTRY]; 114 + await mockServer.setup(page); 115 + await login(page); 116 + 117 + await page.goto("/settings/plugins/community"); 118 + await expect(page).toHaveURL(/\/plugins\/community$/); 119 + await expect( 120 + page.locator('#community-plugins-view [data-testid="header-title"]'), 121 + ).toContainText("Community plugins", { timeout: 10000 }); 90 122 }); 91 123 });
+68
tests/unit/specs/router.test.js
··· 802 802 }); 803 803 }); 804 804 805 + describe("redirect routes", () => { 806 + const originalPath = 807 + window.location.pathname + window.location.search + window.location.hash; 808 + const originalState = window.history.state; 809 + 810 + afterEach(() => { 811 + window.history.replaceState(originalState, "", originalPath); 812 + }); 813 + 814 + function createRouter() { 815 + const router = new Router(); 816 + mountRouter(router); 817 + router.addRedirects({ 818 + "/old": () => "/new", 819 + "/old/:id": (params) => `/new/${params.id}`, 820 + }); 821 + router.addRoute("/new", () => Promise.resolve({ name: "new" })); 822 + router.addRoute("/new/:id", () => Promise.resolve({ name: "newDetail" })); 823 + router.renderRoute(() => {}); 824 + return router; 825 + } 826 + 827 + it("keeps redirects separate from routes", () => { 828 + const router = createRouter(); 829 + assert.deepEqual(router.hasRoute("/old"), false); 830 + assert.deepEqual(router.matchRedirect("/old"), "/new"); 831 + assert.deepEqual(router.matchRedirect("/old/123"), "/new/123"); 832 + assert.deepEqual(router.matchRedirect("/unrelated"), null); 833 + }); 834 + 835 + it("loads the target route and replaces the URL", async () => { 836 + const router = createRouter(); 837 + await router.load("/old"); 838 + assert.deepEqual(window.location.pathname, "/new"); 839 + assert.deepEqual(router.$currentRoute.get().route, "/new"); 840 + }); 841 + 842 + it("passes route params to the redirect function", async () => { 843 + const router = createRouter(); 844 + await router.load("/old/123"); 845 + assert.deepEqual(window.location.pathname, "/new/123"); 846 + assert.deepEqual(router.$currentRoute.get().params, { id: "123" }); 847 + }); 848 + 849 + it("preserves the query string across the redirect", async () => { 850 + const router = createRouter(); 851 + await router.load("/old?foo=bar&baz=1"); 852 + assert.deepEqual(window.location.pathname, "/new"); 853 + assert.deepEqual(window.location.search, "?foo=bar&baz=1"); 854 + assert.deepEqual(router.$currentRoute.get().route, "/new"); 855 + }); 856 + 857 + it("replaces the history entry instead of pushing", async () => { 858 + const router = createRouter(); 859 + window.history.replaceState(null, "", "/old"); 860 + const lengthBefore = window.history.length; 861 + await router.load("/old"); 862 + assert.deepEqual(window.history.length, lengthBefore); 863 + }); 864 + 865 + it("preserves the existing history state across the redirect", async () => { 866 + const router = createRouter(); 867 + window.history.replaceState({ previousRoute: "/prior" }, "", "/old"); 868 + await router.load("/old"); 869 + assert.deepEqual(window.history.state?.previousRoute, "/prior"); 870 + }); 871 + }); 872 + 805 873 describe("route options", () => { 806 874 it("stores options on the route and returns them from match", () => { 807 875 const router = new Router();