Skip to content

Fix the tombstone flow, and watch what Cyd actually sends - #717

Merged
micahflee merged 10 commits into
mainfrom
issue-712-verify-x-repair
Sep 15, 2026
Merged

micahflee merged 10 commits into
mainfrom
issue-712-verify-x-repair

Conversation

@micahflee

@micahflee micahflee commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Closes #712.

Work from the manual verification in #712. The X automation was already
repaired; everything here was found by running it by hand against a seeded
test account, which is what that ticket exists to do.

Tombstone

Five bugs, none in the automation, all in what surrounds it.

  • The finished page rendered its heading above nothing. It has a block per
    jobs type and none matched tombstone, so the one run whose purpose is
    changing how your profile looks to other people never said what it changed.
  • The bio field offered the bio the tombstone had just replaced. Cyd's
    saved copy is written at login and nowhere else. The job now writes back the
    value it reads off X to confirm the save, on the success path only.
  • Waiting for a crop step X does not always show turned a run that was
    about to succeed into a timeout.
  • The banner read-back asked before the page had drawn anything, got ""
    from both reads, and called a save that worked unchanged.
  • The banner read-back asked once, in a 200ms gap. The profile draws the
    banner it already had and replaces it a moment later, so a successful save
    read as no change. It is read now until it differs.

Watching the requests

CYD_REQUEST_LOG=/tmp/dir npm start records the method, URL and status of
what Cyd's own session sends. Off unless asked for. No bodies, headers or
cookies, and never attached to an error report — the reasoning ADR 0005
applies to archives applies here too.

It was built because the capture tooling drives a separate Chromium, so its
findings describe Chromium. That is fine until the app and the probe disagree,
which is where this stood: a banner that uploaded under Playwright and did not
under Electron, with nothing recorded on the Electron side to compare.

It answered that in minutes, and the answer was not what any of the DOM
theories predicted:

20:00:18.471  POST 201  i/media/upload.json?command=FINALIZE
20:00:25.671  POST 200  account/update_profile.json
                        update_profile_banner.json   ← never called

The image uploads; the call attaching it never happens. Apply is not a step X
sometimes puts in the way — it is what stages the upload as the banner. So a
delivery drawing no crop is made again, up to three times, and if X never
offers one that is the error, said plainly.

This also removes a test that asserted saving without a crop succeeds. It
passed, and it was wrong: nothing had watched what X was really sent.

Seeding

Found while re-seeding the test account.

  • The composer close check never matched. /compose/post is a modal over
    the home timeline, whose own composer carries the same test id, so the wait
    retargeted to it the moment the modal closed. Every successful post was
    reported as a failure and retried — a 41-post seed would have triple-posted.
  • Likes and bookmarks reached for a climbing index against a virtualised
    timeline where acted-on posts leave the selector. 4 failures in 41 likes
    before, 0 after acting on the first post still offering the action.
  • The scroll gave up short once enough posts were done, on an image-heavy
    feed.
  • Long-form is gone. It needed a premium account, so it was always skipped
    and never seeded. Accepted knowingly: no seeded post, no fixture, no marker.

Verification

Seeded @nexamind91326 and verified against the account, not the progress log:
41 posts, 41 likes, 41 bookmarks, 25 following, plus a self-thread, one-image,
four-image and video posts, a poll, a link card, 2 quotes and 2 retweets.

The delete run, read back out of the request log: DeleteTweet 51,
DeleteRetweet 2, UnfavoriteTweet 42, DeleteBookmark 42,
friendships/destroy 25 — every call 200, no rate limits, and the account
left empty on every timeline. Zero direct-message API calls across 3,444
requests, which confirms against real traffic what view_model.test.ts
asserts.

The tombstone has since run three times in a row without failing, across two
accounts.

Suite: 119 files, 1499 tests, no type errors.

What merging this accepts

This closes #712, so it is worth being explicit about what the ticket's
remaining criteria rest on.

The delete run is verified above, against the request log and the resulting
account state. The tombstone has run three times without failing. The full
suite is green.

The save run, the un-loadable-timeline error report and the empty-account run
were exercised by hand rather than recorded here. The direct-message archive
criterion was struck from the ticket by decision, not by test: the data half
stays covered by archiveBuild keeps saved conversations and messages in the archive, and the archive site's messages view remains untested by anything,
which is a known and accepted gap.

🤖 Generated with Claude Code

micahflee and others added 10 commits September 15, 2026 11:04
X opens /compose/post as a modal over the home timeline, and that timeline
has an inline composer carrying the same test identifier. Playwright
re-resolves .first() on every poll, so the moment the modal closed the wait
retargeted to the inline composer, which stays attached forever.

The wait could therefore never succeed. Every post that actually landed was
reported as a failure and retried up to three times, which on a 41-post seed
means triple-posting and a daily cap spent on duplicates. Seen for real:
thread-001 posted, was called a failure, and went back for another try.

Scoping the wait to the dialog's own field is what says the post landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The long-form shape needed a premium account and X's own composer, so the
seeder could only ever hand it to a person to do by hand. There is no premium
account and there is not going to be one, which made it a step that was always
skipped and a shape that was never seeded.

So it goes, rather than staying as a permanently unreachable branch: the kind,
the manual action, the shape marker, the switch case, both assertions, and the
README passages that promised it. The walk checklist and the indexing test's
note go with it, since neither describes anything that can happen now.

Long-form now has no representation anywhere — no seeded post, no fixture, no
marker. That is a risk accepted knowingly: it was already the case that no
fixture could vouch for the shape, and inventing one would assert what the
capture never saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h to find it

The seeder counted how many likes it had made and reached for that index in
the timeline. Both halves of that work against how X behaves.

X virtualizes the timeline: posts scrolled well past are dropped from the DOM,
so the number rendered stays roughly flat however far down the feed you get. A
liked post also leaves the `like` selector entirely, its test identifier
flipping to `unlike`. So the index climbed while the count did not, and past
eight or so actions it could never be reached.

The first match is always the next one to act on, for the reason that broke
the old approach: what has been done is no longer in the set. The per-kind
counters existed only to feed that index, so they go too.

That alone was not enough. Every fifteen actions the seeder reloads the feed,
which puts it back above everything it has already acted on, so the scroll
needed to reach the next one grows with the count. On an image-heavy feed a
single photo post runs to most of a screen, and thirty already-bookmarked
posts sat further down than eight turns of the wheel could reach.

Both failures looked identical to a selector X had changed, which is the
reason to fix them rather than record them: they were filling the findings
log with evidence of our own bugs.

Measured on one run of @nexamind91326: 4 failures in 41 likes before the first
change, one skipped bookmark after it, and none across bookmarks 31-41 and
twenty-five follows after the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The finished page has a block per kind of run — save, archive, delete, and both
Bluesky migrations — each keyed off the jobs type stored when the run started.
The tombstone wizard stores "tombstone", and no block matched it, so the page
rendered its "Here's what I did" heading above nothing at all.

The one run whose whole purpose is changing how your profile looks to other
people was the one run that would not say what it had changed.

It lists what was actually turned on, the way the delete block does with
unfollowing: the banner, the bio, and the lock are each shown only if that
part of the tombstone was enabled. There are no counters to report here, so
the account settings are the only thing to read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tombstone page pre-fills its bio field from the bio Cyd has saved, which
is deliberate: it offers you what is on your profile now, rather than whatever
tombstone text you last typed. But that saved copy is written during login and
nowhere else, so the job that changes the bio left it holding the old value.

Come back to the page after tombstoning and it offered the bio the tombstone
had just replaced.

The job already reads the bio back off X to confirm the save landed, so the
value worth keeping is right there and is known to be what X holds. It gets
written to the account only on that path, so a bio that did not change leaves
the saved copy alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The banner job waited for the crop step's Apply button as though X always
showed one. It does not. Seen today on @nexamind91326: the banner went
straight into the profile dialog, ready to save, and the job spent its full
timeout waiting for an Apply button that was never coming — then filed an
automated error report about a run that was otherwise about to succeed.

The probe on 2026-09-15 saw the crop three times out of three, which is
exactly why waiting for it looked safe. Three observations of a thing X does
sometimes is not evidence that X does it always.

So the crop is taken when offered and skipped when not, on a shorter timeout
so a run that will not get one is not held up. Nothing is lost by not
insisting: the banner is read back off the profile afterwards either way, and
that read is what says the save landed. Which steps X put in the way is not
something worth failing over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading the banner back is what says a save landed, and the read was happening
before X had drawn anything. The page load finishes, the header photo arrives
after it, and asking at that moment matches no element and comes back "".

Both reads did that, before and after. Two empty strings match, so the job
announced that the banner had not changed — on a save that had gone through
and could be watched arriving in the webview. The check meant to catch a
silent no-op was manufacturing one.

Seen on @nexamind91326 with bannerBefore and bannerAfter both "", against a
webview still showing its loading spinner.

So the read waits for the banner to exist before asking for its URL. A profile
with no banner never draws one, and that wait running out is an answer — there
is no banner — rather than something to fail over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture tooling answers questions by driving a separate Chromium, so its
findings are about Chromium. That is fine until the app and the probe disagree,
which is where this stands today: a tombstone banner that uploads under
Playwright and does not under Electron, with nothing recorded on the Electron
side to compare.

The session already has a listener on every request, reading operation
identifiers and rate limits off them. It now also records the method, the URL
and the status, when CYD_REQUEST_LOG names a directory to write to:

  CYD_REQUEST_LOG=/tmp/cyd-requests npm start

Which answers, for a run that has already failed, whether a call was made at
all and what came back. For the banner that is the whole question: no
i/media/upload means Cyd never uploads, and an upload that returns an error
means X refused it. Those need different fixes and nothing so far tells them
apart.

Off unless asked for, and it holds no bodies, no headers and no cookies. What
it records is the traffic of a signed-in session, so it stays on the machine:
it is never attached to an error report. A line at a time, because the runs
worth reading back are the ones that did not reach the end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recorded from Cyd's own session, on a run that worked:

  19:55:53.443  POST 200  update_profile_banner.json
  19:55:55.962  GET  200  profile_banners/.../1789501624
  19:55:56.163  GET  200  profile_banners/.../1789502153

X took the banner. Then the profile page drew the banner it already had, and
replaced it two hundred milliseconds later. The read-back asked once, in that
gap, and got the old URL — which matches the one read before the save, so the
job announced that the banner had not changed and filed an error report about
a save that had gone through.

That is the whole of it. Waiting for the image element to exist did not help:
it exists immediately, holding the old source. It is a race, which is why this
failed four times and then passed with nothing altered in between, and why
every explanation that fit the failures had to also explain the success.

So the banner is read until it differs from the one before, rather than
sampled once. A banner that never turns over still reports unchanged, which is
the case this check was built for.

Found with CYD_REQUEST_LOG, which existed for about four minutes before it
paid for itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recorded from Cyd's own session, on a run that failed:

  20:00:17.681  POST 202  i/media/upload.json?command=INIT
  20:00:18.005  POST 204  i/media/upload.json?command=APPEND
  20:00:18.471  POST 201  i/media/upload.json?command=FINALIZE
  20:00:25.671  POST 200  account/update_profile.json

The image uploaded. Then Save wrote the name and the bio, and
update_profile_banner.json was never called at all. Next to a run that worked,
that call is the only difference.

So Apply is not a step X sometimes puts in the way, which is what the last
change assumed. It is what stages the uploaded image as the banner, and a
delivery that draws no crop has staged nothing. Carrying on to Save from there
uploads a file, attaches it to nothing, and comes back reporting a banner that
did not change — which describes none of what happened, and is what four
error reports today were actually made of.

The banner now goes on again, up to three times, until X offers a crop. If it
never does, that is the error, said plainly, rather than a save that cannot
work followed by a read-back left to describe the wreckage.

Takes out the test that asserted a save without a crop succeeds. It passed,
and it was wrong: nothing had watched what X was really sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@micahflee
micahflee merged commit 7012351 into main Sep 15, 2026
1 check passed
@micahflee
micahflee deleted the issue-712-verify-x-repair branch September 15, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Verify the X repair end to end and release

1 participant