| 1 | diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go |
| 2 | index 56a1a50..379e64a 100644 |
| 3 | --- a/internal/webui/browse_test.go |
| 4 | +++ b/internal/webui/browse_test.go |
| 5 | @@ -49,7 +49,7 @@ func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) { |
| 6 | {"/repo?owner=alice&repo=notes&view=file", "browserepo", "one"}, |
| 7 | {"/files", "browsefiles", "deadbeef"}, |
| 8 | {"/file?hash=deadbeef", "browsefile", "blob"}, |
| 9 | - {"/manage/status", "browsestatus", "storage"}, |
| 10 | + {"/manage/status", "browsestatus", "manage this relay"}, |
| 11 | } |
| 12 | for _, test := range tests { |
| 13 | recorder := httptest.NewRecorder() |
| 14 | @@ -79,11 +79,17 @@ func TestConnectPublishesGRASPAndDeliveryKinds(t *testing.T) { |
| 15 | recorder := httptest.NewRecorder() |
| 16 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil)) |
| 17 | body := recorder.Body.String() |
| 18 | - for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} { |
| 19 | + for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `src="/scripts/components.js?v=`} { |
| 20 | if !strings.Contains(body, marker) { |
| 21 | t.Fatalf("connect page missing %q", marker) |
| 22 | } |
| 23 | } |
| 24 | + components := servedScript(t, app, "/scripts/components.js") |
| 25 | + for _, marker := range []string{`customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} { |
| 26 | + if !strings.Contains(components, marker) { |
| 27 | + t.Fatalf("components script missing %q", marker) |
| 28 | + } |
| 29 | + } |
| 30 | } |
| 31 | |
| 32 | func TestBrowseRendersTypedGitRelayPage(t *testing.T) { |
| 33 | @@ -141,3 +147,14 @@ func (b *typedBrowseBackend) Query(_ context.Context, method string, _ []json.Ra |
| 34 | } |
| 35 | return b.browseBackend.Query(context.Background(), method, nil, "") |
| 36 | } |
| 37 | + |
| 38 | +// servedScript fetches one of the cached browser scripts through the app. |
| 39 | +func servedScript(t *testing.T, app *App, path string) string { |
| 40 | + t.Helper() |
| 41 | + recorder := httptest.NewRecorder() |
| 42 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) |
| 43 | + if recorder.Code != http.StatusOK { |
| 44 | + t.Fatalf("%s: status=%d", path, recorder.Code) |
| 45 | + } |
| 46 | + return recorder.Body.String() |
| 47 | +} |
| 48 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 49 | index f1386c3..609dab7 100644 |
| 50 | --- a/internal/webui/components.js |
| 51 | +++ b/internal/webui/components.js |
| 52 | @@ -220,6 +220,9 @@ |
| 53 | connectedCallback() { |
| 54 | if (this.bound) return; |
| 55 | this.bound = true; |
| 56 | + // The encryption and messaging modules load on demand; the controls |
| 57 | + // render right away and decryption waits for them. |
| 58 | + this.ready = Promise.resolve(tiny.require?.("files")).catch(() => {}); |
| 59 | this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button> <share-link></share-link></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>'; |
| 60 | this.output = this.querySelector("output"); |
| 61 | this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI())); |
| 62 | @@ -229,7 +232,7 @@ |
| 63 | const params = new URLSearchParams(location.hash.slice(1)); |
| 64 | this.querySelector("[data-share]").hidden = !(params.get("key") && params.get("iv")); |
| 65 | this.updateFileControls(); |
| 66 | - this.decryptFromFragment(); |
| 67 | + this.ready.then(() => this.decryptFromFragment()); |
| 68 | } |
| 69 | |
| 70 | hash() { return this.getAttribute("hash") || ""; } |
| 71 | @@ -279,6 +282,7 @@ |
| 72 | |
| 73 | async share(form) { |
| 74 | if (this.sharing) return; |
| 75 | + await this.ready; |
| 76 | const recipient = form.elements.namedItem("recipient").value.trim(); |
| 77 | if (!recipient) { this.say("Enter a public key or npub.", true); return; } |
| 78 | const params = new URLSearchParams(location.hash.slice(1)); |
| 79 | @@ -820,6 +824,20 @@ |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | + // ensureModules loads the feature bundle for any custom element on the |
| 84 | + // page whose definition has not arrived, such as after an in-place |
| 85 | + // navigation to the Files or Connect pages. |
| 86 | + const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"}; |
| 87 | + const ensureModules = () => { |
| 88 | + if (!tiny.require || typeof customElements.get !== "function") return; |
| 89 | + const wanted = new Set(); |
| 90 | + for (const [tag, bundle] of Object.entries(bundlesFor)) { |
| 91 | + if (!customElements.get(tag) && document.querySelector(tag)) wanted.add(bundle); |
| 92 | + } |
| 93 | + wanted.forEach(bundle => tiny.require(bundle).catch(() => {})); |
| 94 | + }; |
| 95 | + ensureModules(); |
| 96 | + |
| 97 | customElements.define("rpc-form", RpcForm); |
| 98 | customElements.define("connect-card", ConnectCard); |
| 99 | customElements.define("connect-list", ConnectList); |
| 100 | @@ -902,6 +920,7 @@ |
| 101 | if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true}); |
| 102 | else document.querySelector("#content")?.focus({preventScroll: true}); |
| 103 | document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}})); |
| 104 | + ensureModules(); |
| 105 | decorate(); |
| 106 | decorateForms(); |
| 107 | document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false})); |
| 108 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 109 | index a02731a..aa4e6c6 100644 |
| 110 | --- a/internal/webui/page.html |
| 111 | +++ b/internal/webui/page.html |
| 112 | @@ -1,5 +1,5 @@ |
| 113 | {{define "page"}}<!doctype html> |
| 114 | -<html lang="en" data-js="no"> |
| 115 | +<html lang="en" data-js="no" data-scripts="{{scriptsVersion}}"> |
| 116 | <head> |
| 117 | <meta charset="utf-8"> |
| 118 | <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> |
| 119 | @@ -62,16 +62,16 @@ |
| 120 | <td id="aside"><div id="panel">{{template "panel" .}}</div></td> |
| 121 | </tr></tbody></table> |
| 122 | <footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> » {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table></footer> |
| 123 | -<script>{{script "tiny.js"}}</script> |
| 124 | -<script>{{script "bridge.js"}}</script> |
| 125 | -<script>{{script "blossom-encryption.js"}}</script> |
| 126 | -<script>{{script "blossom-manifests.js"}}</script> |
| 127 | -<script>{{script "blossom-upload.js"}}</script> |
| 128 | -<script>{{script "file-messages.js"}}</script> |
| 129 | -<script>{{script "file-workspace.js"}}</script> |
| 130 | -<script>{{script "components.js"}}</script> |
| 131 | -<script>{{script "private-services.js"}}</script> |
| 132 | -</body> |
| 133 | +<script src="{{scriptURL "tiny.js"}}"></script> |
| 134 | +<script src="{{scriptURL "bridge.js"}}"></script> |
| 135 | +{{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script> |
| 136 | +<script src="{{scriptURL "blossom-manifests.js"}}"></script> |
| 137 | +<script src="{{scriptURL "blossom-upload.js"}}"></script> |
| 138 | +<script src="{{scriptURL "file-messages.js"}}"></script> |
| 139 | +<script src="{{scriptURL "file-workspace.js"}}"></script> |
| 140 | +{{end}}<script src="{{scriptURL "components.js"}}"></script> |
| 141 | +{{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script> |
| 142 | +{{end}}</body> |
| 143 | </html> |
| 144 | {{end}} |
| 145 | |
| 146 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 147 | index 3ac1755..eaf2116 100644 |
| 148 | --- a/internal/webui/shell_test.go |
| 149 | +++ b/internal/webui/shell_test.go |
| 150 | @@ -327,11 +327,14 @@ func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) { |
| 151 | recorder := httptest.NewRecorder() |
| 152 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil)) |
| 153 | body := recorder.Body.String() |
| 154 | - for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`, `customElements.define("relay-lists"`} { |
| 155 | + for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`} { |
| 156 | if !strings.Contains(body, want) { |
| 157 | t.Errorf("account page missing %q", want) |
| 158 | } |
| 159 | } |
| 160 | + if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `customElements.define("relay-lists"`) { |
| 161 | + t.Error("components script missing relay-lists") |
| 162 | + } |
| 163 | recorder = httptest.NewRecorder() |
| 164 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) |
| 165 | body = recorder.Body.String() |
| 166 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 167 | index ff19462..2043fe7 100644 |
| 168 | --- a/internal/webui/style.css |
| 169 | +++ b/internal/webui/style.css |
| 170 | @@ -36,7 +36,8 @@ img{max-width:100%} |
| 171 | #prompt{color:var(--muted)}#prompt b{color:var(--ink);font-weight:500} |
| 172 | #who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)} |
| 173 | #who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline} |
| 174 | -#rail-tools{margin-top:12px} |
| 175 | +#railbox{box-sizing:border-box;padding-bottom:44px} |
| 176 | +#rail-tools{position:absolute;right:0;bottom:0;margin:0;padding:10px 2ch 10px 2.5ch} |
| 177 | share-link{display:inline-block}share-link:empty{display:none} |
| 178 | #theme{border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono)}#theme:hover{color:var(--ink);text-decoration:underline} |
| 179 | |
| 180 | @@ -201,7 +202,8 @@ html,body{overflow:auto} |
| 181 | #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto} |
| 182 | #layout{padding:40px 0 28px} |
| 183 | #railbox,#content,#panel{position:static;overflow:visible} |
| 184 | -#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:auto} |
| 185 | +#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:hidden auto} |
| 186 | +#railbox{position:relative;min-height:100%} |
| 187 | #topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)} |
| 188 | #layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)} |
| 189 | #content>section{padding:14px 2.5ch 20px} |
| 190 | diff --git a/internal/webui/sw.js b/internal/webui/sw.js |
| 191 | index d412cd6..8621ba6 100644 |
| 192 | --- a/internal/webui/sw.js |
| 193 | +++ b/internal/webui/sw.js |
| 194 | @@ -3,7 +3,7 @@ |
| 195 | // page loads and the shell scripts are kept as the offline fallback; relay |
| 196 | // data requested by scripts is never cached. |
| 197 | const CACHE = "tiny-shell-v1"; |
| 198 | -const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|icon[^/]*\.(svg|png)|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname); |
| 199 | +const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|scripts\/[\w.-]+\.js|icon[^/]*\.(svg|png)|badge-96\.png|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname); |
| 200 | self.addEventListener("install", () => self.skipWaiting()); |
| 201 | self.addEventListener("activate", event => { |
| 202 | event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())); |
| 203 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 204 | index e130967..1401d56 100644 |
| 205 | --- a/internal/webui/templates.go |
| 206 | +++ b/internal/webui/templates.go |
| 207 | @@ -1,11 +1,14 @@ |
| 208 | package webui |
| 209 | |
| 210 | import ( |
| 211 | + "crypto/sha256" |
| 212 | "embed" |
| 213 | + "encoding/hex" |
| 214 | "encoding/json" |
| 215 | "fmt" |
| 216 | "html/template" |
| 217 | "net/url" |
| 218 | + "sort" |
| 219 | "strings" |
| 220 | "time" |
| 221 | ) |
| 222 | @@ -145,17 +148,36 @@ func repoView(query url.Values) string { |
| 223 | return "tree" |
| 224 | } |
| 225 | |
| 226 | +// scripts are served at /scripts/<name> and cached by the service worker. |
| 227 | +// One version stamp covers them all, so a change to any file refreshes every |
| 228 | +// cached copy together. |
| 229 | +var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS} |
| 230 | + |
| 231 | +var scriptsVersion = func() string { |
| 232 | + names := make([]string, 0, len(scripts)) |
| 233 | + for name := range scripts { |
| 234 | + names = append(names, name) |
| 235 | + } |
| 236 | + sort.Strings(names) |
| 237 | + digest := sha256.New() |
| 238 | + for _, name := range names { |
| 239 | + digest.Write([]byte(name)) |
| 240 | + digest.Write([]byte{0}) |
| 241 | + digest.Write([]byte(scripts[name])) |
| 242 | + } |
| 243 | + return hex.EncodeToString(digest.Sum(nil))[:12] |
| 244 | +}() |
| 245 | + |
| 246 | func parseTemplates() (*template.Template, error) { |
| 247 | - scripts := map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS} |
| 248 | funcs := template.FuncMap{ |
| 249 | "stylesheet": func() template.CSS { return template.CSS(styleCSS) }, |
| 250 | - "script": func(name string) (template.JS, error) { |
| 251 | - source, ok := scripts[name] |
| 252 | - if !ok { |
| 253 | + "scriptURL": func(name string) (string, error) { |
| 254 | + if _, ok := scripts[name]; !ok { |
| 255 | return "", fmt.Errorf("unknown script %q", name) |
| 256 | } |
| 257 | - return template.JS(source), nil |
| 258 | + return "/scripts/" + name + "?v=" + scriptsVersion, nil |
| 259 | }, |
| 260 | + "scriptsVersion": func() string { return scriptsVersion }, |
| 261 | "json": func(value any) string { |
| 262 | encoded, err := json.Marshal(value) |
| 263 | if err != nil { |
| 264 | diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js |
| 265 | index a7f7100..6716f3e 100644 |
| 266 | --- a/internal/webui/tiny.js |
| 267 | +++ b/internal/webui/tiny.js |
| 268 | @@ -68,6 +68,30 @@ |
| 269 | }; |
| 270 | tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer); |
| 271 | tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer); |
| 272 | + // Feature bundles load on the pages that use them and again after an |
| 273 | + // in-place navigation lands on such a page. Each script loads once. |
| 274 | + tiny.bundles = { |
| 275 | + files: ["blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"], |
| 276 | + private: ["private-services.js"] |
| 277 | + }; |
| 278 | + const loaded = new Map(); |
| 279 | + const loadScript = name => { |
| 280 | + if (!loaded.has(name)) { |
| 281 | + const version = document.documentElement.dataset.scripts || ""; |
| 282 | + const present = [...(document.scripts || [])].some(script => (script.src || "").includes("/scripts/" + name)); |
| 283 | + loaded.set(name, present ? Promise.resolve() : new Promise((resolve, reject) => { |
| 284 | + const script = document.createElement("script"); |
| 285 | + script.src = (tiny.localPath ? tiny.localPath("/scripts/" + name) : "/scripts/" + name) + (version ? "?v=" + version : ""); |
| 286 | + script.onload = () => resolve(); |
| 287 | + script.onerror = () => { loaded.delete(name); reject(Error("Could not load " + name)); }; |
| 288 | + document.head.append(script); |
| 289 | + })); |
| 290 | + } |
| 291 | + return loaded.get(name); |
| 292 | + }; |
| 293 | + tiny.require = async bundle => { |
| 294 | + for (const name of tiny.bundles[bundle] || [bundle]) await loadScript(name); |
| 295 | + }; |
| 296 | // Feature modules add to these groups; guards can read them before load. |
| 297 | tiny.blossom = tiny.blossom || {}; |
| 298 | tiny.files = tiny.files || {}; |
| 299 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 300 | index 57bf9a8..f751837 100644 |
| 301 | --- a/internal/webui/webui.go |
| 302 | +++ b/internal/webui/webui.go |
| 303 | @@ -189,6 +189,21 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 304 | a.invitePage(writer, request) |
| 305 | return |
| 306 | } |
| 307 | + if strings.HasPrefix(request.URL.Path, "/scripts/") { |
| 308 | + source, ok := scripts[strings.TrimPrefix(request.URL.Path, "/scripts/")] |
| 309 | + if !ok { |
| 310 | + http.NotFound(writer, request) |
| 311 | + return |
| 312 | + } |
| 313 | + writer.Header().Set("content-type", "application/javascript; charset=utf-8") |
| 314 | + if request.URL.Query().Get("v") == scriptsVersion { |
| 315 | + writer.Header().Set("cache-control", "public, max-age=31536000, immutable") |
| 316 | + } else { |
| 317 | + writer.Header().Set("cache-control", "no-cache") |
| 318 | + } |
| 319 | + _, _ = writer.Write([]byte(source)) |
| 320 | + return |
| 321 | + } |
| 322 | if request.URL.Path == "/signer.js" { |
| 323 | writer.Header().Set("content-type", "application/javascript; charset=utf-8") |
| 324 | _, _ = writer.Write([]byte(signerJS)) |
| 325 | diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go |
| 326 | index e3ce614..e73f8fd 100644 |
| 327 | --- a/internal/webui/webui_test.go |
| 328 | +++ b/internal/webui/webui_test.go |
| 329 | @@ -229,11 +229,14 @@ func TestSignerBundleAndDedicatedJourneysArePresent(t *testing.T) { |
| 330 | recorder := httptest.NewRecorder() |
| 331 | app.ServeHTTP(recorder, request) |
| 332 | body := recorder.Body.String() |
| 333 | - for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "27235", "/signer.js"} { |
| 334 | + for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "/signer.js", `src="/scripts/bridge.js?v=`} { |
| 335 | if !strings.Contains(body, want) { |
| 336 | t.Errorf("connect page missing %q", want) |
| 337 | } |
| 338 | } |
| 339 | + if !strings.Contains(servedScript(t, app, "/scripts/bridge.js"), "27235") { |
| 340 | + t.Error("bridge script missing the NIP-98 kind") |
| 341 | + } |
| 342 | signerRequest := httptest.NewRequest(http.MethodGet, "/signer.js", nil) |
| 343 | signerResponse := httptest.NewRecorder() |
| 344 | app.ServeHTTP(signerResponse, signerRequest) |
| 345 | @@ -343,11 +346,20 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) { |
| 346 | recorder := httptest.NewRecorder() |
| 347 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil)) |
| 348 | body := recorder.Body.String() |
| 349 | - for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `localPath(action || "/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} { |
| 350 | + for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `src="/r/alice/scripts/bridge.js?v=`, `src="/r/alice/scripts/components.js?v=`} { |
| 351 | if !strings.Contains(body, want) { |
| 352 | t.Errorf("tenant prefix missing %q", want) |
| 353 | } |
| 354 | } |
| 355 | + bridge := servedScript(t, app, "/scripts/bridge.js") |
| 356 | + for _, want := range []string{`signedSession("/session")`, `replace(/^http/,"ws")+root`} { |
| 357 | + if !strings.Contains(bridge, want) { |
| 358 | + t.Errorf("bridge script missing %q", want) |
| 359 | + } |
| 360 | + } |
| 361 | + if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `localPath(action || "/manage/rpc")`) { |
| 362 | + t.Error("components script missing the prefixed management endpoint") |
| 363 | + } |
| 364 | } |
| 365 | |
| 366 | func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) { |