| 1 | diff --git a/docs/fixi.md b/docs/fixi.md |
| 2 | index f2de929..7c3c95e 100644 |
| 3 | --- a/docs/fixi.md |
| 4 | +++ b/docs/fixi.md |
| 5 | @@ -8,7 +8,7 @@ The vendored file's SHA-256 is: |
| 6 | b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855 |
| 7 | ``` |
| 8 | |
| 9 | -`/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Connect page uses Fixi's `fx-action` fragment request for a read-only connection preview; `/connect/fragment` renders server-escaped table HTML, while `/connect.json` remains a JSON API. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them. |
| 10 | +`/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Sync page uses Fixi's `fx-action` request for the job status stream, which renders server-escaped table HTML; `/connect.json` remains a JSON API for the configured connections. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them. |
| 11 | |
| 12 | The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones. |
| 13 | |
| 14 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 15 | index 1eb60aa..3e91365 100644 |
| 16 | --- a/internal/webui/components.js |
| 17 | +++ b/internal/webui/components.js |
| 18 | @@ -5,6 +5,9 @@ |
| 19 | // <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]> |
| 20 | // One signed NIP-86 management call. Children become the form body; the |
| 21 | // element appends an <output> status line and a <json-view> result. |
| 22 | +// <connect-list> |
| 23 | +// The home page cards: lists, reorders, retargets and removes configured |
| 24 | +// connections and adds from the catalog, saving with setconnections. |
| 25 | // <signed-form action="…" method="PUT|POST|GET" [download]> |
| 26 | // A NIP-98 signed upload of one file, or a signed download by name. |
| 27 | // <publish-list kind="…" tag="…" [scheme="ws"]> |
| 28 | @@ -84,6 +87,24 @@ |
| 29 | }] |
| 30 | }; |
| 31 | |
| 32 | + // rpc makes one signed management call: the body is serialised once, |
| 33 | + // signed as NIP-98, and sent once. Returns the result or throws the error. |
| 34 | + const rpc = async (method, params = [], action) => { |
| 35 | + const body = JSON.stringify({method, params}); |
| 36 | + const endpoint = tiny.localPath(action || "/manage/rpc"); |
| 37 | + const url = new URL(endpoint, location.href).href; |
| 38 | + const response = await fetch(endpoint, { |
| 39 | + method: "POST", |
| 40 | + headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))}, |
| 41 | + body |
| 42 | + }); |
| 43 | + const text = await response.text(); |
| 44 | + let payload; |
| 45 | + try { payload = JSON.parse(text); } catch { payload = undefined; } |
| 46 | + if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")"); |
| 47 | + return payload && "result" in payload ? payload.result : payload ?? text; |
| 48 | + }; |
| 49 | + |
| 50 | class RpcForm extends FormElement { |
| 51 | params(form) { |
| 52 | const compose = composers[this.getAttribute("compose")]; |
| 53 | @@ -105,20 +126,8 @@ |
| 54 | const terms = this.getAttribute("terms"); |
| 55 | if (terms) params = [params[0], await tiny.sha256hex(new TextEncoder().encode(terms))]; |
| 56 | } |
| 57 | - const body = JSON.stringify({method, params}); |
| 58 | this.report("Signing…"); |
| 59 | - const endpoint = tiny.localPath(this.getAttribute("action")||"/manage/rpc"); |
| 60 | - const url = new URL(endpoint, location.href).href; |
| 61 | - const response = await fetch(endpoint, { |
| 62 | - method: "POST", |
| 63 | - headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))}, |
| 64 | - body |
| 65 | - }); |
| 66 | - const text = await response.text(); |
| 67 | - let payload; |
| 68 | - try { payload = JSON.parse(text); } catch { payload = undefined; } |
| 69 | - if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")"); |
| 70 | - const result = payload && "result" in payload ? payload.result : payload ?? text; |
| 71 | + const result = await rpc(method, params, this.getAttribute("action")); |
| 72 | this.report("Done."); |
| 73 | this.show(result); |
| 74 | } |
| 75 | @@ -390,6 +399,130 @@ |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | + // ConnectList edits the cards on the home page: the configured connections |
| 80 | + // in order, each with its audience, plus add from the catalog. Every change |
| 81 | + // saves the whole list with one signed call. |
| 82 | + class ConnectList extends HTMLElement { |
| 83 | + connectedCallback() { |
| 84 | + if (this.bound) return; |
| 85 | + this.bound = true; |
| 86 | + this.body = this.querySelector("tbody"); |
| 87 | + this.select = this.querySelector("select[name=template]"); |
| 88 | + this.output = this.querySelector("output"); |
| 89 | + this.rows = []; |
| 90 | + this.catalog = []; |
| 91 | + this.addEventListener("click", event => { |
| 92 | + const button = event.target.closest("button[name]"); |
| 93 | + if (!button) return; |
| 94 | + button.disabled = true; |
| 95 | + this.change(button.name, button.closest("tr")).catch(err => this.say("error: " + err.message, true)).finally(() => { button.disabled = false; }); |
| 96 | + }); |
| 97 | + this.addEventListener("change", event => { |
| 98 | + const select = event.target.closest("select[name=visibility]"); |
| 99 | + if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true)); |
| 100 | + }); |
| 101 | + this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true))); |
| 102 | + } |
| 103 | + |
| 104 | + // whenSigned waits briefly for a signer, since a remote one resumes async. |
| 105 | + whenSigned(fn, tries = 20) { |
| 106 | + if (window.nostr?.signEvent) return fn(); |
| 107 | + if (tries <= 0) { this.say("connect a signer, then reload this page"); return; } |
| 108 | + setTimeout(() => this.whenSigned(fn, tries - 1), 500); |
| 109 | + } |
| 110 | + |
| 111 | + say(text, error) { |
| 112 | + this.output.textContent = text; |
| 113 | + if (error) this.output.dataset.error = ""; |
| 114 | + else delete this.output.dataset.error; |
| 115 | + } |
| 116 | + |
| 117 | + async load() { |
| 118 | + this.say("loading…"); |
| 119 | + [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]); |
| 120 | + this.render(); |
| 121 | + this.say(""); |
| 122 | + } |
| 123 | + |
| 124 | + entry(name) { return this.catalog.find(c => c.name === name) || {name, title: name, visibility: "public", available: true}; } |
| 125 | + |
| 126 | + render() { |
| 127 | + const audiences = ["public", "members", "owner"]; |
| 128 | + const rows = this.rows.map((row, index) => { |
| 129 | + const name = row.template || row.name; |
| 130 | + const c = this.entry(name); |
| 131 | + const tr = el("tr"); |
| 132 | + tr.dataset.index = index; |
| 133 | + tr.dataset.template = name; |
| 134 | + const app = el("td"); |
| 135 | + if (c.app) app.append(el("b", c.app), " "); |
| 136 | + app.append(c.about || ""); |
| 137 | + if (c.available === false) app.append(" ", el("small", "(feature off, hidden)")); |
| 138 | + const audience = el("select"); |
| 139 | + audience.name = "visibility"; |
| 140 | + const current = row.visibility === "auth" || row.visibility === "member" ? "members" : row.visibility || c.visibility; |
| 141 | + for (const value of audiences.includes(current) ? audiences : [current, ...audiences]) { |
| 142 | + const option = el("option", value); |
| 143 | + option.value = value; |
| 144 | + option.selected = value === current; |
| 145 | + audience.append(option); |
| 146 | + } |
| 147 | + const actions = el("td"); |
| 148 | + for (const [label, action] of [["up", "up"], ["down", "down"], ["remove", "remove"]]) { |
| 149 | + const button = el("button", label); |
| 150 | + button.type = "button"; |
| 151 | + button.name = action; |
| 152 | + if ((action === "up" && index === 0) || (action === "down" && index === this.rows.length - 1)) button.disabled = true; |
| 153 | + actions.append(button, " "); |
| 154 | + } |
| 155 | + const cell = el("td"); |
| 156 | + cell.append(audience); |
| 157 | + tr.append(el("td", c.title || name), app, cell, actions); |
| 158 | + return tr; |
| 159 | + }); |
| 160 | + if (!rows.length) { |
| 161 | + const empty = el("tr"); |
| 162 | + const cell = el("td", "No cards. Add one from the catalog below."); |
| 163 | + cell.colSpan = 4; |
| 164 | + empty.append(cell); |
| 165 | + rows.push(empty); |
| 166 | + } |
| 167 | + this.body.replaceChildren(...rows); |
| 168 | + const used = new Set(this.rows.map(row => row.template || row.name)); |
| 169 | + const options = [el("option", "choose one")]; |
| 170 | + options[0].value = ""; |
| 171 | + for (const c of this.catalog) { |
| 172 | + if (used.has(c.name)) continue; |
| 173 | + const option = el("option", c.title + (c.app ? " (" + c.app + ")" : "") + (c.available === false ? ", feature off" : "")); |
| 174 | + option.value = c.name; |
| 175 | + options.push(option); |
| 176 | + } |
| 177 | + this.select.replaceChildren(...options); |
| 178 | + } |
| 179 | + |
| 180 | + async change(action, row, value) { |
| 181 | + const index = Number(row?.dataset.index); |
| 182 | + const next = this.rows.map(entry => ({...entry})); |
| 183 | + switch (action) { |
| 184 | + case "remove": next.splice(index, 1); break; |
| 185 | + case "up": if (index > 0) [next[index - 1], next[index]] = [next[index], next[index - 1]]; break; |
| 186 | + case "down": if (index < next.length - 1) [next[index + 1], next[index]] = [next[index], next[index + 1]]; break; |
| 187 | + case "visibility": next[index].visibility = value; break; |
| 188 | + case "add": { |
| 189 | + const name = this.select.value; |
| 190 | + if (!name) { this.say("choose a card to add"); return; } |
| 191 | + next.push({template: name, visibility: this.entry(name).visibility || "public"}); |
| 192 | + break; |
| 193 | + } |
| 194 | + default: return; |
| 195 | + } |
| 196 | + this.say("saving…"); |
| 197 | + this.rows = await rpc("setconnections", [next]); |
| 198 | + this.render(); |
| 199 | + this.say("saved"); |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | // ConnectCard copies a command or address, filling {input:name} from the |
| 204 | // card's inputs, so a clone command carries the repository name typed. |
| 205 | class ConnectCard extends HTMLElement { |
| 206 | @@ -413,6 +546,7 @@ |
| 207 | |
| 208 | customElements.define("rpc-form", RpcForm); |
| 209 | customElements.define("connect-card", ConnectCard); |
| 210 | + customElements.define("connect-list", ConnectList); |
| 211 | customElements.define("relay-lists", RelayLists); |
| 212 | customElements.define("signed-form", SignedForm); |
| 213 | customElements.define("publish-list", PublishList); |
| 214 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 215 | index c481c02..b3a04e4 100644 |
| 216 | --- a/internal/webui/manage.html |
| 217 | +++ b/internal/webui/manage.html |
| 218 | @@ -101,11 +101,15 @@ |
| 219 | <h1>Connect</h1> |
| 220 | {{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p> |
| 221 | {{end}}<h3>Connections</h3> |
| 222 | -<p><button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button></p> |
| 223 | -<scroll-box><table id="connections-preview" aria-live="polite"><tbody><tr><td>No preview loaded.</td></tr></tbody></table></scroll-box> |
| 224 | -<rpc-form method="listconnections"><button>List connections</button></rpc-form> |
| 225 | -<rpc-form method="setconnections"><label>JSON connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form> |
| 226 | -<rpc-form method="listconnectiontemplates"><button>List templates</button></rpc-form> |
| 227 | +<p>The cards on the home page, in order. Remove what you will not use; add more from the catalog. Cards whose feature is off stay hidden.</p> |
| 228 | +<connect-list> |
| 229 | +<table id="rows"><thead><tr><th>card</th><th>app</th><th>audience</th><th></th></tr></thead><tbody><tr><td colspan="4">Loading…</td></tr></tbody></table> |
| 230 | +<p><label>Add a card <select name="template"><option value="">choose one</option></select></label> <button type="button" name="add">add</button></p> |
| 231 | +<output role="status"></output> |
| 232 | +</connect-list> |
| 233 | +<details><summary>Edit as JSON</summary> |
| 234 | +<rpc-form method="setconnections"><label>Connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form> |
| 235 | +</details> |
| 236 | <h3>Publish signed relay records</h3> |
| 237 | <p>These actions open the configured signer and publish owner-authored Nostr records.</p> |
| 238 | <publish-list kind="10317" tag="g" scheme="ws"> |
| 239 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 240 | index 5b67461..d1485a4 100644 |
| 241 | --- a/internal/webui/style.css |
| 242 | +++ b/internal/webui/style.css |
| 243 | @@ -111,6 +111,7 @@ rpc-form output,signed-form output,publish-list output{display:block;font:12px v |
| 244 | rpc-form output[data-error]{color:var(--alert)} |
| 245 | #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px} |
| 246 | |
| 247 | +connect-list{display:block}connect-list #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}connect-list #rows td:first-child{width:150px}connect-list #rows td:nth-child(2){color:var(--muted)}connect-list #rows td:nth-child(2) b{color:var(--ink);font-weight:normal}connect-list #rows td:nth-child(3){width:110px}connect-list #rows td:last-child{width:190px;white-space:nowrap}connect-list button,connect-list select{font-size:12px}connect-list output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px}connect-list output[data-error]{color:var(--alert)} |
| 248 | relay-lists{display:block}relay-lists #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}relay-lists #rows td:last-child{width:230px;text-align:left;color:var(--ink);white-space:nowrap}relay-lists #rows td:first-child{width:170px;color:var(--ink);font-family:var(--sans)}relay-lists output{display:block;font:12px var(--mono);color:var(--muted);white-space:normal}relay-lists button{font-size:12px} |
| 249 | |
| 250 | connect-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}connect-card h3{margin:0 0 4px;border:0;font-size:15px}connect-card h3 small{font:12px var(--mono);color:var(--muted)}connect-card p{margin:0 0 6px;color:var(--muted)}connect-card label{display:inline-block;font:13px var(--sans);color:var(--muted);margin:0 10px 6px 0}connect-card label input{display:block;font:13px var(--mono);min-width:200px}connect-card a,connect-card button{margin-right:10px;font-size:13px}connect-card output{display:block;font:12px var(--mono);color:var(--muted);overflow-wrap:anywhere}connect-card details img{display:block;margin:6px 0;background:#fff;padding:6px;border:1px solid var(--line)} |
| 251 | @@ -194,6 +195,6 @@ html,body{overflow:auto} |
| 252 | #navigation-status:not(:empty),#session-status:not(:empty){position:absolute;bottom:100%;left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal} |
| 253 | #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto} |
| 254 | #tree{border-right:0;border-bottom:1px solid var(--line)} |
| 255 | -#rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px} |
| 256 | +#rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}connect-list #rows td:nth-child(2),connect-list #rows th:nth-child(2){display:none}connect-list #rows td:nth-child(3),connect-list #rows td:last-child{width:auto}connect-list #rows td:last-child{white-space:normal}connect-list #rows td:last-child button{margin:2px 0} |
| 257 | #grid,#gridhead,h2:has(+#gridhead){display:none} |
| 258 | } |
| 259 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 260 | index dfbf966..764afeb 100644 |
| 261 | --- a/internal/webui/webui.go |
| 262 | +++ b/internal/webui/webui.go |
| 263 | @@ -167,10 +167,6 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 264 | a.connectJSON(writer, request) |
| 265 | return |
| 266 | } |
| 267 | - if request.URL.Path == "/connect/fragment" { |
| 268 | - a.connectFragment(writer, request) |
| 269 | - return |
| 270 | - } |
| 271 | if strings.HasPrefix(request.URL.Path, "/e/") || strings.HasPrefix(request.URL.Path, "/a/") { |
| 272 | if !a.pageFeatureEnabled(request.URL.Path) { |
| 273 | http.NotFound(writer, request) |
| 274 | @@ -404,27 +400,6 @@ func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) { |
| 275 | writeJSON(writer, http.StatusOK, result) |
| 276 | } |
| 277 | |
| 278 | -func (a *App) connectFragment(writer http.ResponseWriter, request *http.Request) { |
| 279 | - actor, actorErr := a.resolveActor(request) |
| 280 | - if actorErr != nil || actor == "" { |
| 281 | - http.Error(writer, "authentication required", http.StatusUnauthorized) |
| 282 | - return |
| 283 | - } |
| 284 | - result, err := a.backend.Query(request.Context(), "listconnections", nil, actor) |
| 285 | - if err != nil { |
| 286 | - http.Error(writer, "connections unavailable", http.StatusNotFound) |
| 287 | - return |
| 288 | - } |
| 289 | - rows := make([][]string, 0) |
| 290 | - for _, row := range browseRows(result) { |
| 291 | - values, _ := row.(map[string]any) |
| 292 | - rows = append(rows, []string{plainString(firstValue(values, "label", "title", "name", "template")), plainString(firstValue(values, "url", "href", "relay")), plainString(firstValue(values, "status", "visibility"))}) |
| 293 | - } |
| 294 | - fragment := `<table id="connections-preview" aria-live="polite">` + tableRows([]string{"Label", "Relay", "Status"}, rows) + `</table>` |
| 295 | - writer.Header().Set("content-type", "text/html; charset=utf-8") |
| 296 | - _, _ = writer.Write([]byte(fragment)) |
| 297 | -} |
| 298 | - |
| 299 | func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) { |
| 300 | actor, actorErr := a.resolveActor(request) |
| 301 | if actorErr != nil { |
| 302 | diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go |
| 303 | index e58cea7..31c0831 100644 |
| 304 | --- a/internal/webui/webui_test.go |
| 305 | +++ b/internal/webui/webui_test.go |
| 306 | @@ -313,16 +313,24 @@ func TestJobStatusRequiresActorAndEmitsHTMLSSE(t *testing.T) { |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | -func TestConnectionFragmentEscapesValues(t *testing.T) { |
| 311 | +func TestConnectPageEditsConnectionsAsList(t *testing.T) { |
| 312 | backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))} |
| 313 | - app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "owner", nil }}) |
| 314 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }}) |
| 315 | if err != nil { |
| 316 | t.Fatal(err) |
| 317 | } |
| 318 | recorder := httptest.NewRecorder() |
| 319 | - app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/connect/fragment", nil)) |
| 320 | - if recorder.Code != http.StatusOK || !strings.Contains(recorder.Header().Get("content-type"), "text/html") || !strings.Contains(recorder.Body.String(), "connections-preview") { |
| 321 | - t.Fatalf("invalid connection fragment: status=%d body=%s", recorder.Code, recorder.Body.String()) |
| 322 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil)) |
| 323 | + body := recorder.Body.String() |
| 324 | + for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`} { |
| 325 | + if !strings.Contains(body, want) { |
| 326 | + t.Errorf("connect page missing %q", want) |
| 327 | + } |
| 328 | + } |
| 329 | + for _, gone := range []string{"connect/fragment", "Preview connections", `method="listconnections"`} { |
| 330 | + if strings.Contains(body, gone) { |
| 331 | + t.Errorf("connect page still carries %q", gone) |
| 332 | + } |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | @@ -333,9 +341,9 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | recorder := httptest.NewRecorder() |
| 340 | - app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/connect", nil)) |
| 341 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil)) |
| 342 | body := recorder.Body.String() |
| 343 | - for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/connect/fragment"`, `localPath(this.getAttribute("action")||"/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} { |
| 344 | + 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`} { |
| 345 | if !strings.Contains(body, want) { |
| 346 | t.Errorf("tenant prefix missing %q", want) |
| 347 | } |
| 348 | @@ -348,11 +356,11 @@ func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) { |
| 349 | if err != nil { |
| 350 | t.Fatal(err) |
| 351 | } |
| 352 | - request := httptest.NewRequest(http.MethodGet, "/manage/connect", nil) |
| 353 | - request.RequestURI = "/r/alice/manage/connect" |
| 354 | + request := httptest.NewRequest(http.MethodGet, "/manage/sync", nil) |
| 355 | + request.RequestURI = "/r/alice/manage/sync" |
| 356 | recorder := httptest.NewRecorder() |
| 357 | app.ServeHTTP(recorder, request) |
| 358 | - if !strings.Contains(recorder.Body.String(), `fx-action="/r/alice/connect/fragment"`) { |
| 359 | + if !strings.Contains(recorder.Body.String(), `fx-action="/r/alice/manage/jobs/status"`) { |
| 360 | t.Fatal("daemon prefix header was not applied") |
| 361 | } |
| 362 | } |
| 363 | diff --git a/scripts/qa/ux-browser-flow.js b/scripts/qa/ux-browser-flow.js |
| 364 | index 5d0f116..eb86111 100644 |
| 365 | --- a/scripts/qa/ux-browser-flow.js |
| 366 | +++ b/scripts/qa/ux-browser-flow.js |
| 367 | @@ -59,29 +59,24 @@ async page => { |
| 368 | check(current.length > 0 && current.every(x => x.form && x.output && !x.nested), 'detached or nested forms: '+JSON.stringify(current)); |
| 369 | } |
| 370 | await page.waitForFunction(() => !!window.nostr?.signEvent); |
| 371 | - const list = page.locator('rpc-form[method=listconnections]'); |
| 372 | + await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null); |
| 373 | + check(await page.locator('connect-list select[name=template] option').count() > 1, 'connect list offers no catalog cards'); |
| 374 | + await clickNav('/people'); |
| 375 | + const list = page.locator('rpc-form[method=listmembers]'); |
| 376 | let submissions = 0; |
| 377 | const count = request => { if (request.method()==='POST' && request.url().endsWith('/manage/rpc')) submissions++; }; |
| 378 | page.on('request', count); |
| 379 | try { |
| 380 | await list.evaluate(node => { node.form.requestSubmit(); node.form.requestSubmit(); }); |
| 381 | - await page.waitForFunction(() => document.querySelector('rpc-form[method=listconnections] output')?.textContent === 'Done.'); |
| 382 | + await page.waitForFunction(() => document.querySelector('rpc-form[method=listmembers] output')?.textContent === 'Done.'); |
| 383 | check(submissions===1, 'duplicate form submissions sent '+submissions+' signed requests'); |
| 384 | } finally { page.off('request',count); } |
| 385 | - await page.getByRole('button', {name:'Preview connections',exact:true}).click(); |
| 386 | - await page.waitForFunction(() => document.querySelector('#connections-preview thead') !== null); |
| 387 | + await clickNav('/connect'); |
| 388 | + await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null); |
| 389 | const result = await forms(); |
| 390 | await page.screenshot({path:'output/playwright/ux/connect-desktop.png'}); |
| 391 | return result; |
| 392 | }); |
| 393 | - await run('refused previews show an error without destroying the table', async () => { |
| 394 | - await page.evaluate(async () => { await fetch(window.tiny.localPath('/session/logout'), {method:'POST'}); }); |
| 395 | - try { |
| 396 | - await page.getByRole('button', {name:'Preview connections',exact:true}).click(); |
| 397 | - await page.waitForFunction(() => document.querySelector('#navigation-status')?.textContent.includes('Sign in again')); |
| 398 | - check(await page.locator('#connections-preview').count()===1, 'refused preview removed table'); |
| 399 | - } finally { await page.evaluate(async () => { await window.tiny.signedFetch('/session','POST',''); }); } |
| 400 | - }); |
| 401 | await run('job status stream closes when leaving the page', async () => { |
| 402 | await clickNav('/sync'); |
| 403 | await page.locator('#watch-jobs').click(); |