Side A fixes several real bugs (regex split instead of string split, correct getRequestBody, nil-safe state/token handling, proper error handling to prevent silent hangs) that were causing legitimate test crashes in the OAuth mock infrastructure, restoring E2E test reliability. Side B is a reasonable simplification/refactor of a UI action into inline SSR, but it's a smaller-scope cleanup with less clear bug-fixing value and removes test coverage without adding new safety guarantees.
constitution · epochs · watch · epoch 3
c_597d3f736194 (tommy-mor) vs c_f515f8a12d7a (tommy-mor)
download prompt · raw event · cmp_7314c83a398d99
council reasoning
A repairs concrete mock-OAuth/Reddit failures (string split on "=" vs regex, getRequestBody vs getInputStream, null-safe token/state handling, response headers, handler try/catch) that had broken Playwright auth E2E, plus selector fixes in the login helper. B is a worthwhile UX consistency cleanup (SSR #new-thread-ui-slot and delete ExpandNewThreadForm/toolbar) but removes a working indirection rather than fixing correctness or restoring critical test capability.
Side A fixes concrete failures in the test infrastructure by correcting query parsing (`str/split` regex), reading POST bodies from `getRequestBody`, handling null tokens/states safely, fixing redirect responses, and wrapping the mock OAuth handler to avoid crashes, restoring end-to-end authentication tests. Side B is primarily a UI simplification that removes the `ExpandNewThreadForm` action and server-renders the existing compose slot on the home page, which reduces code but is a design cleanup rather than a broad correctness fix.
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_f515f8a12d7a (tommy-mor)
message
[601d3a05] fix(html): drop home toolbar + and ExpandNewThreadForm (single + flow) Public home now SSRs #new-thread-ui-slot like room pages: collapsed compose for signed-in users, login hint when logged out. Removes the extra toolbar that morphed the same collapsed state and the expand_new_thread_form action. Made-with: Cursor
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index e979053ff1ba8c0e2bf55add0a32b7de11cf1e56..f3ce5cb2ab2f923440a8479d0f1fb4acbba166ca 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -139,51 +139,6 @@ async fn dispatch_ui_action(
}
}
}
- HtmlUiAction::ExpandNewThreadForm { room_wire } => {
- let room_wire = room_wire.trim().to_string();
- if room_wire.is_empty() {
- return ui_js_warn("missing room").into_response();
- }
- if room_wire == "public" {
- let reduced = state.reduced.read().await;
- let user = session.map(|s| s.username.as_str());
- drop(reduced);
- let markup = if user.is_some() {
- fragment_new_thread_slot(&ThreadNav::public(), true, false)
- } else {
- login_to_post_hint_markup()
- };
- return JsBuilder::new()
- .morph_inner_selector("#new-thread-ui-slot", markup)
- .into_response();
- }
- let reduced = state.reduced.read().await;
- let user = session.map(|s| s.username.as_str());
- if !reduced.rooms.contains(&room_wire) {
- drop(reduced);
- return ui_js_warn("room not found").into_response();
- }
- if !user_can_view_room(&reduced, &room_wire, user) {
- drop(reduced);
- return ui_js_warn("forbidden").into_response();
- }
- let can_post = session
- .as_ref()
- .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))
- .unwrap_or(false);
- drop(reduced);
- let Some(nav) = ThreadNav::from_room_id(&room_wire) else {
- return ui_js_warn("bad room").into_response();
- };
- let markup = if can_post {
- fragment_new_thread_slot(&nav, true, false)
- } else {
- login_to_post_hint_markup()
- };
- JsBuilder::new()
- .morph_inner_selector("#new-thread-ui-slot", markup)
- .into_response()
- }
HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => {
let room_wire = room_wire.trim().to_string();
if room_wire.is_empty() {
diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs
index 1b4ae7baa3ad4757b74172d7b67f3f2b33d1075d..945bdd6c48bc164e2cf91fd0996c4321b75f5abf 100644
--- a/server/src/html/forum/feed.rs
+++ b/server/src/html/forum/feed.rs
@@ -14,6 +14,7 @@ use crate::timeago;
use super::ingest::ingest_entry_markup;
use super::nav::ThreadNav;
+use super::new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};
use super::page::auth_strip;
use super::paginator::{render_thread_paginator, PAGE_SIZE};
use crate::html::{
@@ -217,9 +218,6 @@ pub async fn home(
let strip = auth_strip(&headers, &jar, &reduced_read);
drop(reduced_read);
- use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD};
- use crate::form_template::template_json_compact;
-
let page = layout(
"slug.social",
"view-thread",
@@ -243,15 +241,13 @@ pub async fn home(
}
}
p class="muted" { "dark = time-ordered · light = vote-ranked" }
- div class="thread-feed-toolbar" {
- form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandNewThreadForm {
- room_wire: "public".into(),
- }).expect("static json"));
- button type="submit" class="section-add-btn" { "+" }
+ div id="new-thread-ui-slot" {
+ @if user.is_some() {
+ (fragment_new_thread_slot(&nav, true, false))
+ } @else {
+ (login_to_post_hint_markup())
}
}
- div id="new-thread-ui-slot" {}
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
(cli_panel(&["npx slugsocial public forum list"]))
},
diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs
index 5031ebfeb928f28e471f23210b8c644654adb7c7..da0c9b3541e8e4988a78768cddef22324755c3f2 100644
--- a/server/src/html/ui_action.rs
+++ b/server/src/html/ui_action.rs
@@ -38,11 +38,6 @@ pub enum HtmlUiAction {
RedactPost {
post_id: String,
},
- /// Morph `#new-thread-ui-slot` inner to the collapsed compose toggle (or login hint).
- /// Use `room_wire: "public"` for the public forum home; otherwise a private room id (`short/slug`).
- ExpandNewThreadForm {
- room_wire: String,
- },
/// Morph `#room-members-section` — members list open or collapsed (server-rendered).
SetRoomMembersExpanded {
room_wire: String,
@@ -131,26 +126,6 @@ mod tests {
);
}
- #[test]
- fn expand_new_thread_form_public() {
- let template = serde_json::json!({
- "action": "expand_new_thread_form",
- "room_wire": "public",
- });
- let mut form = HashMap::new();
- form.insert(
- UI_RPC_FIELD.to_string(),
- serde_json::to_string(&template).unwrap(),
- );
- let a = parse_html_ui_from_form(&form).unwrap();
- assert_eq!(
- a,
- HtmlUiAction::ExpandNewThreadForm {
- room_wire: "public".into(),
- }
- );
- }
-
#[test]
fn expand_post_full_round_trip() {
let template = serde_json::json!({
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.