Side A fixes real bugs that were breaking E2E auth tests (wrong str/split arg types, incorrect InputStream/RequestBody API usage, nil state handling, missing error handling causing crashes), restoring test suite functionality. Side B is a UI refactor consolidating multiple cli_panel calls into one, which is a reasonable improvement but is feature polish rather than a correctness fix, and introduces a slightly awkward assert-based safety check instead of proper escaping.
constitution · epochs · watch · epoch 3
c_597d3f736194 (tommy-mor) vs c_ca72f0995396 (tommy-mor)
download prompt · raw event · cmp_f34466b3f87cf8
council reasoning
A restores broken E2E auth by fixing real mock failures (str/split on "=" vs #"=", slurp of getRequestBody vs getInputStream, 302 body length, nil-safe token/state, handler try/catch) plus selector/async test reads. B only regroups the cosmetic cli_panel UI (multi-cmd, row click-to-copy, CSS, JS-quote asserts)—nice polish, not unblocking core test/infra correctness.
Side A fixes concrete failures in the OAuth end-to-end test infrastructure by correcting query parsing (`str/split` with regex), reading POST bodies from `getRequestBody`, handling null tokens/states safely, adjusting redirects, wrapping mock handlers with error handling, and improving Playwright DOM polling so authentication flows work again. Side B is primarily a UI enhancement that refactors `cli_panel` to support grouped clickable commands with styling and adds assertions for JS-safe command strings, which is useful but less foundational than restoring broken authentication tests.
sides
A — c_597d3f736194 (tommy-mor)
message
[075d4d37] Fix OAuth test mocks so Clojure E2E auth flows work again. HttpServer handlers were crashing on query parsing and token POSTs, which broke Playwright login; also read alias/history via real CSS selectors. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/test/auth_login.clj b/test/auth_login.clj
index 6f2f00d7aa7340e63d1ac465b0a2234cec7a983f..aa63b66ccb2471b710ba3d168d0461252b39fc10 100644
--- a/test/auth_login.clj
+++ b/test/auth_login.clj
@@ -14,27 +14,27 @@
(defn- type-alias! [pg text]
(page/evaluate pg
(.replace
- "(() => { const i = document.getElementById('alias-input'); const f = document.getElementById('alias-check-form'); if (!i || !f) return;
+ "(() => { const i = document.getElementById('alias-input'); const f = document.getElementById('alias-check-form'); if (!i || !f) return Promise.resolve('missing-form');
i.value = __TEXT__;
const cf = document.getElementById('alias-claim-field'); if (cf) cf.value = i.value;
return fetch(f.action, { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(f)).toString() })
.then(function (r) { return r.text(); })
- .then(function (t) { eval(t); }); })()"
+ .then(function (t) { eval(t); return document.getElementById('alias-status')?.textContent || ''; }); })()"
"__TEXT__"
- (pr-str text)))
- (Thread/sleep 400))
+ (pr-str text))))
-(defn- element-text [pg test-id]
+(defn- element-text [pg selector]
(let [raw (page/evaluate pg
- (str "document.querySelector('[data-testid=\"" test-id "\"]')?.textContent || ''"))]
+ (str "document.querySelector(" (pr-str selector) ")?.textContent || ''"))]
(when (string? raw) (str/trim raw))))
(defn- wait-for-text [pg test-id text timeout-ms]
- (let [deadline (+ (System/currentTimeMillis) timeout-ms)]
+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)
+ selector (str "[data-testid=\"" test-id "\"]")]
(loop []
- (let [got (or (element-text pg test-id) "")]
+ (let [got (or (element-text pg selector) "")]
(cond
(= got text) got
(< (System/currentTimeMillis) deadline) (do (Thread/sleep 200) (recur))
diff --git a/test/support/mock_oauth.clj b/test/support/mock_oauth.clj
index 5ba7e9be3cf6d2226648e9609a09ed45f306930f..333fe08d56cb06bac2a338cad1c73894d54c4495 100644
--- a/test/support/mock_oauth.clj
+++ b/test/support/mock_oauth.clj
@@ -7,7 +7,7 @@
(defn- query-param [query key]
(when query
(some (fn [pair]
- (let [[k v] (str/split pair "=" 2)]
+ (let [[k v] (str/split pair #"=" 2)]
(when (= k key)
(URLDecoder/decode (or v "") "UTF-8"))))
(str/split query #"&"))))
@@ -31,11 +31,11 @@
(defn- send-redirect [^HttpExchange ex location]
(.set (.getResponseHeaders ex) "Location" location)
- (.sendResponseHeaders ex 302 -1)
+ (.sendResponseHeaders ex 302 0)
(.close (.getResponseBody ex)))
(defn- read-form [^HttpExchange ex]
- (let [body (slurp (.getInputStream ex))]
+ (let [body (slurp (.getRequestBody ex))]
{:code (query-param body "code")
:grant (query-param body "grant_type")}))
@@ -45,7 +45,7 @@
(str/replace #"^[Bb]earer " "")))
(defn- parse-token-user [token]
- (when (str/starts-with? token "mock:")
+ (when (and token (str/starts-with? token "mock:"))
(parse-mock-user (subs token 5))))
(defn- authorize-redirect [exchange query]
@@ -55,7 +55,7 @@
user (parse-mock-user mock-user)
code (str "mock:" (:id user) ":" (:login user))
loc (str redirect-uri "?code=" (java.net.URLEncoder/encode code "UTF-8")
- "&state=" (java.net.URLEncoder/encode state "UTF-8"))]
+ "&state=" (java.net.URLEncoder/encode (or state "") "UTF-8"))]
(send-redirect exchange loc)))
(defn start-mock-oauth
@@ -65,49 +65,56 @@
handler
(proxy [HttpHandler] []
(handle [^HttpExchange exchange]
- (let [uri (.getRequestURI exchange)
- path (.getPath uri)
- query (.getQuery uri)
- method (.getRequestMethod exchange)]
- (cond
- ;; GitHub authorize
- (str/ends-with? path "/login/oauth/authorize")
- (authorize-redirect exchange query)
+ (try
+ (let [uri (.getRequestURI exchange)
+ path (.getPath uri)
+ query (.getQuery uri)
+ method (.getRequestMethod exchange)]
+ (cond
+ ;; GitHub authorize
+ (str/ends-with? path "/login/oauth/authorize")
+ (authorize-redirect exchange query)
- ;; Reddit authorize
- (str/ends-with? path "/api/v1/authorize")
- (authorize-redirect exchange query)
+ ;; Reddit authorize
+ (str/ends-with? path "/api/v1/authorize")
+ (authorize-redirect exchange query)
- ;; GitHub token
- (and (= method "POST") (str/ends-with? path "/login/oauth/access_token"))
- (let [code (or (:code (read-form exchange)) "mock:1002:newbie")]
- (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\"}")))
+ ;; GitHub token
+ (and (= method "POST") (str/ends-with? path "/login/oauth/access_token"))
+ (let [code (or (:code (read-form exchange)) "mock:1002:newbie")]
+ (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\"}")))
- ;; Reddit token (client_credentials for import + authorization_code for login)
- (and (= method "POST") (str/ends-with? path "/api/v1/access_token"))
- (let [form (read-form exchange)
- grant (or (:grant form) "")
- code (or (:code form) "mock:t2_test:redditor")]
- (if (= grant "client_credentials")
- (send-json exchange 200 "{\"access_token\":\"app-token\",\"token_type\":\"bearer\",\"expires_in\":3600}")
- (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\",\"expires_in\":3600}"))))
+ ;; Reddit token (client_credentials for import + authorization_code for login)
+ (and (= method "POST") (str/ends-with? path "/api/v1/access_token"))
+ (let [form (read-form exchange)
+ grant (or (:grant form) "")
+ code (or (:code form) "mock:t2_test:redditor")]
+ (if (= grant "client_credentials")
+ (send-json exchange 200 "{\"access_token\":\"app-token\",\"token_type\":\"bearer\",\"expires_in\":3600}")
+ (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\",\"expires_in\":3600}"))))
- ;; GitHub user
- (= path "/user")
- (let [token (bearer-token exchange)
- user (or (parse-token-user token) {:id "1002" :login "newbie" :numeric? true})]
- (send-json exchange 200
- (str "{\"id\":" (:id user) ",\"login\":\"" (:login user) "\"}")))
+ ;; GitHub user
+ (= path "/user")
+ (let [token (bearer-token exchange)
+ user (or (parse-token-user token) {:id "1002" :login "newbie" :numeric? true})]
+ (send-json exchange 200
+ (str "{\"id\":" (:id user) ",\"login\":\"" (:login user) "\"}")))
- ;; Reddit /api/v1/me
- (str/ends-with? path "/api/v1/me")
- (let [token (bearer-token exchange)
- user (or (parse-token-user token) {:id "t2_test" :login "redditor"})]
- (send-json exchange 200
- (str "{\"id\":\"" (:id user) "\",\"name\":\"" (:login user) "\"}")))
+ ;; Reddit /api/v1/me
+ (str/ends-with? path "/api/v1/me")
+ (let [token (bearer-token exchange)
+ user (or (parse-token-user token) {:id "t2_test" :login "redditor"})]
+ (send-json exchange 200
+ (str "{\"id\":\"" (:id user) "\",\"name\":\"" (:login user) "\"}")))
- :else
- (send-json exchange 404 "{\"error\":\"not found\"}")))))]
+ :else
+ (send-json exchange 404 "{\"error\":\"not found\"}")))
+ (catch Throwable t
+ (binding [*out* *err*]
+ (println "mock-oauth handler error:" t))
+ (try
+ (send-json exchange 500 "{\"error\":\"mock-oauth internal\"}")
+ (catch Throwable _))))))]
(.createContext server "/" handler)
(.setExecutor server nil)
(.start server)
diff --git a/test/support/mock_reddit.clj b/test/support/mock_reddit.clj
index a630cf0938722193e9af88382d60e777ff371be4..faa27945b6394363914c853627a0bad1e819a5f9 100644
--- a/test/support/mock_reddit.clj
+++ b/test/support/mock_reddit.clj
@@ -12,7 +12,7 @@
(defn- query-param [query key]
(when query
(some (fn [pair]
- (let [[k v] (str/split pair "=" 2)]
+ (let [[k v] (str/split pair #"=" 2)]
(when (= k key)
(URLDecoder/decode (or v "") "UTF-8"))))
(str/split query #"&"))))
@@ -34,11 +34,11 @@
(defn- send-redirect [^HttpExchange ex location]
(.set (.getResponseHeaders ex) "Location" location)
- (.sendResponseHeaders ex 302 -1)
+ (.sendResponseHeaders ex 302 0)
(.close (.getResponseBody ex)))
(defn- read-form [^HttpExchange ex]
- (let [body (slurp (.getInputStream ex))]
+ (let [body (slurp (.getRequestBody ex))]
{:code (query-param body "code")
:grant (query-param body "grant_type")}))
@@ -48,7 +48,7 @@
(str/replace #"^[Bb]earer " "")))
(defn- parse-token-user [token]
- (when (str/starts-with? token "mock:")
+ (when (and token (str/starts-with? token "mock:"))
(parse-mock-user (subs token 5))))
(defn start-mock-reddit
@@ -72,7 +72,7 @@
user (parse-mock-user (query-param query "mock_user"))
code (str "mock:" (:id user) ":" (:login user))
loc (str redirect-uri "?code=" (java.net.URLEncoder/encode code "UTF-8")
- "&state=" (java.net.URLEncoder/encode state "UTF-8"))]
+ "&state=" (java.net.URLEncoder/encode (or state "") "UTF-8"))]
(send-redirect exchange loc))
(and (= method "POST") (str/ends-with? path "/api/v1/access_token"))
B — c_ca72f0995396 (tommy-mor)
message
[798c764d] feat(html): grouped cli_panel with hover-to-copy and JS-safe asserts Single bordered panel for multiple commands; rows copy on click without a separate copy control. Assert CLI strings contain no chars that would break single-quoted onclick JS. Made-with: Cursor
diff preview
diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs
index f6e45b05bb92126966e304619f80ef1d45be7a4b..075860914a6ce18bb0dfa73dc5651ef1a6318b67 100644
--- a/server/src/html/forum.rs
+++ b/server/src/html/forum.rs
@@ -718,7 +718,7 @@ pub async fn home(
}
div id="public-new-thread-ui-slot" {}
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
- (cli_panel("npx slugsocial public forum list"))
+ (cli_panel(&["npx slugsocial public forum list"]))
},
None,
theme_from_jar(&jar),
@@ -868,7 +868,7 @@ async fn thread_view_inner(
div id="thread-live-region" {
(compose_form(&nav, &tag, show_compose))
}
- (cli_panel(&cli))
+ (cli_panel(std::slice::from_ref(&cli)))
},
None,
theme_from_jar(&jar),
@@ -984,9 +984,7 @@ pub async fn room_page(
(new_thread_form_for_room(&nav, true, false))
}
}
- (cli_panel(&forum_cli))
- (cli_panel(&garden_cli))
- (cli_panel(&audit_cli))
+ (cli_panel(&[forum_cli, garden_cli, audit_cli]))
},
None,
theme_from_jar(&jar),
@@ -1409,7 +1407,7 @@ pub async fn user_profile_page(
}
}
}
- (cli_panel(&format!("npx slugsocial public forum list")))
+ (cli_panel(&[format!("npx slugsocial public forum list")]))
},
None,
theme_from_jar(&jar),
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 9b4789a79c3e293cbfc5f033a0eac8650320d94d..c8ce7de450f7d14e04020511e7eb7323bd487deb 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -139,7 +139,7 @@ pub async fn garden_index(
}
}
}
- (cli_panel("npx slugsocial garden tree"))
+ (cli_panel(&["npx slugsocial garden tree"]))
},
None,
theme_from_jar(&jar),
@@ -542,7 +542,7 @@ async fn render_scope_view(
ScopeId::Public => format!("npx slugsocial public garden body {}", path.as_str().trim_start_matches("https://slug.social/~/")),
ScopeId::Room(room_id) => format!("npx slugsocial private {room_id} garden body {}", path.as_str().trim_start_matches("https://slug.social/~/")),
};
- (cli_panel(&cli))
+ (cli_panel(std::slice::from_ref(&cli)))
},
None,
theme_from_jar(&jar),
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index e7f5bfb7a4b2dee2adf96447224c58d641092124..6617781a2e8e86c2e2693788ea7cd0eb0e3659a2 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -599,18 +599,38 @@ pub(super) fn render_linkified_with_embeds_in_scope(raw: &str, garden_prefix: &s
}
}
-/// Small CLI hint panel showing how to look up this page from the terminal.
-pub(super) fn cli_panel(cmd: &str) -> Markup {
+/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.
+fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {
+ assert!(
+ !s.contains('\\')
+ && !s.contains('\'')
+ && !s.contains('\n')
+ && !s.contains('\r'),
+ "cli_panel cmd must not contain `\\`, `'`, or newlines (got {s:?})"
+ );
+}
+
+/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.
+pub(super) fn cli_panel<I: AsRef<str>>(cmds: &[I]) -> Markup {
+ if cmds.is_empty() {
+ return html! {};
+ }
+ for cmd in cmds {
+ assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());
+ }
html! {
div class="cli-panel" {
span class="cli-panel-label muted" { "cli" }
- code class="cli-panel-cmd" { (cmd) }
- button
- class="cli-panel-copy"
- title="Copy to clipboard"
- onclick=(format!(r#"navigator.clipboard.writeText('{}'); this.textContent='✓'; setTimeout(() => this.textContent='copy', 2000);"#, cmd.replace("'", "\\'")))
- {
- "copy"
+ div class="cli-panel-cmds" {
+ @for cmd in cmds {
+ @let s = cmd.as_ref();
+ button type="button" class="cli-panel-row" title="Copy command" onclick=(format!(
+ r#"navigator.clipboard.writeText('{}');"#,
+ s
+ )) {
+ code class="cli-panel-cmd" { (s) }
+ }
+ }
}
}
}
diff --git a/server/src/html/search.rs b/server/src/html/search.rs
index 28ceaa53c3fcac6777311535e95fb771b19438f5..43e6ebf36cf0fe72c96f0f9d850bea51ac094c43 100644
--- a/server/src/html/search.rs
+++ b/server/src/html/search.rs
@@ -418,7 +418,7 @@ pub async fn search_page(
value=(query) autocomplete="off" autofocus;
}
(render_search_results(&results, &query))
- (cli_panel("npx slugsocial search <query>"))
+ (cli_panel(&["npx slugsocial search <query>"]))
},
None,
theme_from_jar(&jar),
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index e024a5c8b74139ae8be37b2a1bd17e4c3abe9324..764b66c8208e91e6138b83c534387cf43c85b8b5 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -610,7 +610,7 @@ code {
CLI PANEL — how to view this page from the terminal
---------------------------------------------------------------- */
div.cli-panel {
- align-items: baseline;
+ align-items: flex-start;
background: var(--g1);
border: var(--bv) solid;
border-color: var(--lo) var(--hi) var(--hi) var(--lo); /* inset */
@@ -621,11 +621,34 @@ div.cli-panel {
width: fit-content;
max-width: 100%;
}
+.cli-panel-cmds {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ min-width: 0;
+}
+button.cli-panel-row {
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: block;
+ font: inherit;
+ margin: 0;
+ padding: 2px 4px;
+ text-align: left;
+ width: 100%;
+}
+button.cli-panel-row:hover {
+ background: var(--g3);
+}
.cli-panel-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
flex-shrink: 0;
+ padding-top: 2px;
}
.cli-panel-cmd {
background: none;
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 00714575bfa533d1d9c66653b9089642e2b0a6ca..f89ecbc3a18eb9b2b27d1f7764330f6bd6987552 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -306,19 +306,41 @@ a.post-nav-btn:hover {
}
div.cli-panel {
- align-items: baseline;
+ align-items: flex-start;
border: 1px dashed var(--line);
display: flex;
- flex-wrap: wrap;
gap: 0.5rem;
margin: 0.65rem 0;
padding: 0.45rem 0.65rem;
}
+.cli-panel-cmds {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ flex: 1;
+ min-width: 0;
+}
+button.cli-panel-row {
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: block;
+ font: inherit;
+ margin: 0;
+ padding: 0.1rem 0.2rem;
+ text-align: left;
+ width: 100%;
+}
+button.cli-panel-row:hover {
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+}
.cli-panel-label {
color: var(--ink-dim);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
+ padding-top: 0.12rem;
}
.cli-panel-cmd {
color: var(--accent);
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.