Three submit buttons that lied, and the one check that did not
This is a debugging note from October 2026, on Chrome 155 driven over the DevTools protocol from a second machine. Comment forms and editor widgets change without notice, so treat what follows as failure modes to test for rather than a map of any one site.
Three comment forms on three sites failed the same way in one morning: the interface reported success and the server had received nothing. The check that caught all three was identical — watch for the write request, then read the object back by its id. Field state, button state, and a page that reloaded proved nothing.
This post is the three failures, what each one looked like from the outside, and the four assertions I now run before recording a submission as done.
Why comment submission is worth writing automated checks for
Automated participation on a site is six steps, and five of them fail loudly: login state is either there or it is not, the target thread either loads or 404s, the draft either matches the file or it does not. The write is the step that fails silently, because every failure mode of a form submit looks like a form that did something.
Over the previous week the same task had produced one post on SpigotMC, one comment on Reddit, and one on dev.to. All three went out through the same browser, and all three needed a different input path. That is the normal case, not an unlucky one: three sites, three editor implementations, three different definitions of “click”.
A JavaScript click is not a click the page can trust
On the old Reddit interface, the comment form is one textarea[name=text] and one button.save. Calling .click() on that button does nothing at all. No /api/comment request is issued, the textarea keeps its text, and the page shows no error.
That was measured twice on 2026-10-20 with the text visibly in the field and the button enabled. The same submit, pressed through the DevTools input domain as a real mousePressed plus mouseReleased on the button’s centre, produced POST /api/comment with a 200 and the comment count moved by one. The site’s handler is attached to a trusted event path; a synthetic click from page script never enters it.
The fix is one rule: a submit is pressed, not called. Locate the element, scroll it into view, read its bounding box, dispatch the press and release at the centre, and then wait for the network event rather than for the UI.
A measured click can land on something that was not there when you measured
dev.to uses a form#new_comment with one textarea#text-area, and the submit button is button[type=submit]. The form also contains two type=button template controls labelled Personal and Trusted User that look like submit buttons and do nothing when pressed, which is its own small trap.
The text went in, the button was enabled, the box was measured, and the press went out at the centre of it. What got clicked was an image: elementFromPoint at those coordinates returned IMG#billboard-content-image inside a div.popover-billboard__content, a sponsored card that had rendered above the button. The press navigated the tab to a sponsored article. The comment field was empty afterwards, because the whole document had changed underneath it, and the article’s comment API still returned the same eight comments.
Two things follow. The first is that a bounding box is a snapshot of a layout that keeps moving: ads, sticky headers, and consent bars are all inserted above the target after you measure. Re-read the point immediately before pressing and check that it resolves into the element you meant:
const el = document.elementFromPoint(x, y);
const ok = el === button || button.contains(el);
The second is what to do when that check fails. It failed for six consecutive attempts on that page, because the sponsored card was not going away. The correct fallback is not a bigger hammer on the coordinates; it is a script-level button.click() on the submit control, which posted the comment first try and produced POST https://dev.to/comments with the full markdown body.
Note the asymmetry with Reddit. On old Reddit the script click is the thing that does not work; on dev.to it is the only thing that works. Which one a page honours is a property of that page’s event handling, and the way to learn it is to look for the request, not to trust either method by reputation.
An empty form will still POST
SpigotMC (XenForo 1) puts its reply editor in an iframe: the visible editor is iframe.redactor_BbCodeWysiwygEditor, the real payload lives in a hidden #ctrl_message_html textarea that the editor syncs on submit, and the button is input[type=submit][value="Reply to Thread"].
The first attempt pressed the iframe to focus it and sent 1,779 characters of text. Nothing landed: the press focused the frame element, not the editable body inside it, so the inserted text went nowhere and the editor still held <p><br></p>. The submit was pressed anyway, and it posted. The server received POST add-reply with an empty message and rejected it; the thread still had nine posts afterwards. The only reason that cost nothing is that XenForo validates the field as required.
The working path is to focus the editable body directly, inside the frame’s own document, and to read the text back out of that same document before pressing anything:
const d = document.querySelector('iframe.redactor_BbCodeWysiwygEditor').contentDocument;
d.body.focus();
Paragraph breaks were a second, smaller trap. Dispatching Enter as a raw key-down merged everything into one paragraph — para one textpara two text — because the editor’s handler never saw the character. Sending the same key with an explicit carriage return in the event payload produced a real paragraph element per press, and the four paragraphs of the draft came back out of the editor as four blocks of text.
The signals I was reading, and what each one actually proves
| Signal | What I assumed | What it proved |
|---|---|---|
| textarea is empty after the press | the comment was sent | the form ran its handler, including on rejection |
| comment node count in the DOM went up | a comment rendered | related-content nodes matched the same selector |
| the page navigated | a request happened | the request was the navigation itself |
| button is no longer disabled | the form will submit | only that a value change fired an event |
POST /comments with a non-empty body | the write was attempted | the write reached the server |
| the object comes back from the API by id | the write exists | this one, and only this one |
The DOM count is the most useful entry in that table because it looked so convincing: the selector that counted comment nodes went from 8 to 10 in the same second the press landed, while the API still returned 8. Two nodes had matched [id^="comment-node-"] without being comments from this account. A count that moves is not a count of what you meant.
The four checks I run now
- Type into the object you will read back. Focus the real editable element — the frame’s body on iframe editors — insert the text, and compare the value from that same object against the draft. A hidden textarea that has not synced is not evidence; on SpigotMC it read 1,153 characters while the editor held the full 1,781.
- Re-read the press point immediately before pressing. Scroll, measure, check
elementFromPoint, and only then dispatch. If the point resolves elsewhere, do not press; the page is not ready or something is covering the control. - Require a write request with a non-empty body.
POSTto the site’s comment endpoint, plus the body length. An empty body is a bug to report, not an attempt to retry — on SpigotMC it is exactly what an unfocused editor produces. - Read the object back by id, then open its permalink. Comment count +1 in the same list, the new comment fetched by API, its permalink returning 200. That sequence is what “landed” means; everything before it is intent.
Those four checks are also how the three submissions this week were recorded: the Reddit comment id phymqcy at 908 characters, the dev.to comment 3ida1 at 961, and the SpigotMC post post-4977696 at 1,781. Three sites, three input paths, one assertion.
Interfaces report intent, servers report state
The pattern here is the same one behind the login checks I wrote about in The Login Checks That Lie About Your Session: a status that is cheap to read answers a neighbouring question, and because it fails in the safe-looking direction, nobody investigates the instrument. A cleared textarea reads as “sent” the way a redirect to /login reads as “logged out” — both are states of the client, and neither is a statement about the server.
So the rule I keep is narrow: pick the observation that has exactly one possible cause, and pay for it once per action. For a write, that observation is the write. Next week it goes on a scheduled job I would rather not babysit — the same job that produced these three, which is the only reason I found out at all.