From 6e24b3819d296b6e1ecf1af95ab5d403fd528a00 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 11:04:29 -0700 Subject: [PATCH 01/10] Wait for the modal's own composer, not the one behind it 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) --- scripts/x-capture/lib/x_page.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/x-capture/lib/x_page.ts b/scripts/x-capture/lib/x_page.ts index 9c5be45f..a33b7546 100644 --- a/scripts/x-capture/lib/x_page.ts +++ b/scripts/x-capture/lib/x_page.ts @@ -13,6 +13,12 @@ import type { Page } from "playwright-core"; export const SELECTORS = { // Composing composerTextarea: '[data-testid="tweetTextarea_0"]', + // X opens /compose/post as a modal over the home timeline, whose own inline + // composer carries the same test identifier. Waiting on the unscoped + // selector re-resolves to that inline composer the moment the modal closes, + // so it never reports detached and every successful post looks like a + // failure. Waiting for the modal's own field is what says the post landed. + composerDialogTextarea: '[role="dialog"] [data-testid="tweetTextarea_0"]', composerTextareaNth: (index: number) => `[data-testid="tweetTextarea_${index}"]`, composerFileInput: 'input[data-testid="fileInput"]', @@ -215,7 +221,7 @@ async function submitComposer(page: Page) { } const composerClosed = page - .locator(SELECTORS.composerTextarea) + .locator(SELECTORS.composerDialogTextarea) .first() .waitFor({ state: "detached", timeout: 20000 }) .then(() => "closed" as const) From 7c277fa30e2c3327061e758b939e469dacd79411 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 11:04:42 -0700 Subject: [PATCH 02/10] Drop the long-form post the seed can never make MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/x-capture/capture-walk-20260914.md | 1 - scripts/x-capture/README.md | 13 ++++------ scripts/x-capture/lib/seed_plan.test.ts | 2 -- scripts/x-capture/lib/seed_plan.ts | 25 +++---------------- scripts/x-capture/lib/walk_plan.test.ts | 2 +- scripts/x-capture/seed.ts | 1 - .../controller.indexing.integration.test.ts | 5 ++-- 7 files changed, 11 insertions(+), 38 deletions(-) diff --git a/docs/x-capture/capture-walk-20260914.md b/docs/x-capture/capture-walk-20260914.md index fd0ae658..8de121ed 100644 --- a/docs/x-capture/capture-walk-20260914.md +++ b/docs/x-capture/capture-walk-20260914.md @@ -58,7 +58,6 @@ the first cursor. - [ ] Open the permalink of the post with four images - [ ] Open the permalink of the video post - [ ] Open the permalink of the poll -- [ ] Open the permalink of the long-form post, if one was seeded - [ ] Open the permalink of the link-card post - [ ] Open the permalink of the retweet whose original was deleted — if it is not on the timeline at all, that is the finding, and the decoded type diff --git a/scripts/x-capture/README.md b/scripts/x-capture/README.md index fc8958d0..c453ac0f 100644 --- a/scripts/x-capture/README.md +++ b/scripts/x-capture/README.md @@ -122,15 +122,13 @@ post. `--count N` overrides how many filler posts, likes, and bookmarks to make; the default is 61 each, which clears three pages of twenty. The default follow count is 21, enough for the following list to page once. -Two steps stop and ask you to do them in the open browser window, then -continue: +One step stops and asks you to do it in the open browser window, then +continues: -- **The long-form post** needs a premium account and X's own long-form - composer, which the ordinary composer will not stand in for. - **The orphaned retweet** needs two accounts: retweet a post from the second test account, then delete it from that account. -Both are recorded as manual steps rather than as selector failures, so they do +It is recorded as a manual step rather than as a selector failure, so it does not pollute the selector evidence. ### When it stops @@ -275,9 +273,8 @@ Two shapes were deliberately **not** promoted: so there is no JSON body to promote. The status and the `x-rate-limit-*` headers are what a test needs, and they are in `GraphQLResponses.rateLimitError`. -- **The long-form post, and a rate limit disguised as a success.** Neither was - observed, so neither has a fixture. Inventing one would assert a shape this - capture cannot vouch for. +- **A rate limit disguised as a success.** Never observed, so it has no + fixture. Inventing one would assert a shape this capture cannot vouch for. `src/renderer/src/view_models/XViewModel/test_fixtures.test.ts` asserts what each fixture shows, so the findings do not drift away from the files. diff --git a/scripts/x-capture/lib/seed_plan.test.ts b/scripts/x-capture/lib/seed_plan.test.ts index 68500756..1e4cc0c3 100644 --- a/scripts/x-capture/lib/seed_plan.test.ts +++ b/scripts/x-capture/lib/seed_plan.test.ts @@ -104,7 +104,6 @@ describe("kinds", () => { }); test("names the kinds a person has to do", () => { - expect(isManualKind("longform")).toBe(true); expect(isManualKind("retweet-to-orphan")).toBe(true); expect(isManualKind("post")).toBe(false); }); @@ -115,7 +114,6 @@ describe("buildSeedPlan", () => { const counts = summarizePlan(buildSeedPlan(options())); expect(counts.thread).toBe(1); expect(counts.poll).toBe(1); - expect(counts.longform).toBe(1); expect(counts.link).toBe(1); expect(counts.quote).toBe(1); expect(counts.retweet).toBe(1); diff --git a/scripts/x-capture/lib/seed_plan.ts b/scripts/x-capture/lib/seed_plan.ts index 9d1f4407..7b95282a 100644 --- a/scripts/x-capture/lib/seed_plan.ts +++ b/scripts/x-capture/lib/seed_plan.ts @@ -14,7 +14,6 @@ export type SeedActionKind = | "thread" | "poll" | "media" - | "longform" | "link" | "quote" | "retweet" @@ -32,10 +31,10 @@ export const BULK_KINDS: SeedActionKind[] = [ ]; /** - * Kinds no script can do on its own: the long-form composer needs premium, and - * an orphaned retweet needs a second account to delete the original from. + * Kinds no script can do on its own: an orphaned retweet needs a second + * account to delete the original from. */ -export const MANUAL_KINDS: SeedActionKind[] = ["longform", "retweet-to-orphan"]; +export const MANUAL_KINDS: SeedActionKind[] = ["retweet-to-orphan"]; export function isBulkKind(kind: SeedActionKind): boolean { return BULK_KINDS.includes(kind); @@ -167,23 +166,6 @@ function awkwardShapes(options: SeedPlanOptions): SeedAction[] { note: "Post with a link card", text: `Cyd seed link card ${dateStamp}: https://cyd.social/`, }, - { - id: "longform-001", - kind: "longform", - note: "Long-form post. Posted by hand: X's long-form composer needs premium, and the ordinary composer will not take it", - text: [ - `Cyd seed long-form post ${dateStamp}.`, - "", - "This post exists to exercise the long-form note shape, where the body is", - "carried outside the ordinary post text field and the timeline entry is", - "truncated. It has to be long enough that X treats it as long-form rather", - "than an ordinary post, so it runs past the usual character limit and keeps", - "going for several paragraphs.", - "", - "Everything here is seeded test content on a test account and is safe to", - "delete at any point during the capture walk.", - ].join("\n"), - }, ]; targetUrls.slice(0, 2).forEach((targetUrl, index) => { @@ -305,7 +287,6 @@ export const SHAPE_MARKERS: { label: string; pattern: RegExp }[] = [ { label: "Poll", pattern: /Cyd seed poll/ }, { label: "Link card", pattern: /Cyd seed link card/ }, { label: "Quote post", pattern: /Cyd seed quote post/ }, - { label: "Long-form", pattern: /Cyd seed long-form/ }, ]; /** The shape a timeline entry is, or null if it is not a seeded shape. */ diff --git a/scripts/x-capture/lib/walk_plan.test.ts b/scripts/x-capture/lib/walk_plan.test.ts index 07948114..33c152c8 100644 --- a/scripts/x-capture/lib/walk_plan.test.ts +++ b/scripts/x-capture/lib/walk_plan.test.ts @@ -28,7 +28,7 @@ describe("parsePermalinks", () => { test("skips shapes that were never found", () => { const markdown = [ "- [ ] Poll: https://x.com/someone/status/1", - "- [ ] Long-form: **not found on the timeline**", + "- [ ] Link card: **not found on the timeline**", ].join("\n"); expect(parsePermalinks(markdown)).toHaveLength(1); }); diff --git a/scripts/x-capture/seed.ts b/scripts/x-capture/seed.ts index 5a9b7559..3e065a0e 100644 --- a/scripts/x-capture/seed.ts +++ b/scripts/x-capture/seed.ts @@ -247,7 +247,6 @@ async function runAction( case "retweet": await retweetPost(page, requireTarget(action)); break; - case "longform": case "retweet-to-orphan": // Handled above, before the switch. break; diff --git a/src/account_x/__tests__/integration/controller.indexing.integration.test.ts b/src/account_x/__tests__/integration/controller.indexing.integration.test.ts index 147b23ac..cf627ef7 100644 --- a/src/account_x/__tests__/integration/controller.indexing.integration.test.ts +++ b/src/account_x/__tests__/integration/controller.indexing.integration.test.ts @@ -493,9 +493,8 @@ test("indexParseTweets() reads the rate limit reset from the response headers", test("indexParseTweets() saves the rest when one entry cannot be read", async () => { // No fixture can show a retweet whose original was deleted — the entry is - // simply gone from the timeline (findings 5) — and a long-form post was - // never observed either. What both would cost, if X ever returns one Cyd - // cannot read, is the rest of the archive. They do not. + // simply gone from the timeline (findings 5). What that would cost, if X + // ever returns one Cyd cannot read, is the rest of the archive. It does not. const body = JSON.parse( fs.readFileSync( path.join( From 0d7fc3f7181add139822ddb95706ed648d909bcc Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 11:58:03 -0700 Subject: [PATCH 03/10] Act on the first post still offering the action, and scroll far enough 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) --- scripts/x-capture/lib/x_page.ts | 38 ++++++++++++++++++++++++--------- scripts/x-capture/seed.ts | 22 ++++--------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/scripts/x-capture/lib/x_page.ts b/scripts/x-capture/lib/x_page.ts index a33b7546..cc486aef 100644 --- a/scripts/x-capture/lib/x_page.ts +++ b/scripts/x-capture/lib/x_page.ts @@ -354,31 +354,49 @@ async function clickNthAfterScrolling( await assertNotBlocked(page); const buttons = page.locator(selector); - for (let attempt = 0; attempt < 8; attempt++) { + // Every fifteen actions the seeder reloads the feed, which puts it back at + // the top — above everything it has already acted on. Finding the next post + // to act on then means scrolling past all of them, so how far this has to + // reach grows with the count. An image-heavy feed makes that worse: a single + // photo post runs to most of a screen, so forty already-liked posts can sit + // tens of thousands of pixels down. Reaching short of them looks exactly + // like a selector X has changed. + for (let attempt = 0; attempt < 40; attempt++) { if ((await buttons.count()) > index) { await buttons.nth(index).scrollIntoViewIfNeeded(); await buttons.nth(index).click(); return; } - await page.mouse.wheel(0, 2000); + await page.mouse.wheel(0, 4000); await page.waitForTimeout(1500); } throw new SelectorMissingError(selector); } -export async function actOnNextPost( - page: Page, - action: "like" | "bookmark", - alreadyDone: number, -) { +/** + * Acts on the first post still offering the action, rather than counting how + * many have been done and reaching for that index. + * + * X virtualizes the timeline, dropping posts out of the DOM once they are + * scrolled well past, so the number rendered stays roughly flat however far + * down the feed you are. A liked post also leaves the `like` selector + * entirely, since its test identifier flips to `unlike`. Counting upwards + * against that meant 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 same reason: what + * has been done is no longer in the set. + */ +export async function actOnNextPost(page: Page, action: "like" | "bookmark") { await clickNthAfterScrolling( page, action === "like" ? SELECTORS.like : SELECTORS.bookmark, - alreadyDone, + 0, ); } -export async function followNextAccount(page: Page, alreadyDone: number) { - await clickNthAfterScrolling(page, SELECTORS.follow, alreadyDone); +/** The first account still offering Follow, for the reason above. */ +export async function followNextAccount(page: Page) { + await clickNthAfterScrolling(page, SELECTORS.follow, 0); } diff --git a/scripts/x-capture/seed.ts b/scripts/x-capture/seed.ts index 3e065a0e..d4d5c53c 100644 --- a/scripts/x-capture/seed.ts +++ b/scripts/x-capture/seed.ts @@ -211,9 +211,6 @@ function requireTarget(action: SeedAction): string { } interface FeedState { - liked: number; - bookmarked: number; - followed: number; actionsSinceReload: number; } @@ -253,28 +250,22 @@ async function runAction( case "like": if (feed.actionsSinceReload === 0) { await page.goto(feedUrl, { waitUntil: "domcontentloaded" }); - feed.liked = 0; } - await actOnNextPost(page, "like", feed.liked); - feed.liked += 1; + await actOnNextPost(page, "like"); break; case "bookmark": if (feed.actionsSinceReload === 0) { await page.goto(feedUrl, { waitUntil: "domcontentloaded" }); - feed.bookmarked = 0; } - await actOnNextPost(page, "bookmark", feed.bookmarked); - feed.bookmarked += 1; + await actOnNextPost(page, "bookmark"); break; case "follow": if (feed.actionsSinceReload === 0) { await page.goto("https://x.com/i/connect_people", { waitUntil: "domcontentloaded", }); - feed.followed = 0; } - await followNextAccount(page, feed.followed); - feed.followed += 1; + await followNextAccount(page); break; } } @@ -349,12 +340,7 @@ async function main() { return; } - const feed: FeedState = { - liked: 0, - bookmarked: 0, - followed: 0, - actionsSinceReload: 0, - }; + const feed: FeedState = { actionsSinceReload: 0 }; for (const action of plan) { if (progress.isDone(action.id)) { From c50a88ba73b4bd0d248aa3e7f2a4559d114acf55 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 12:05:36 -0700 Subject: [PATCH 04/10] Tell people what the tombstone did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/i18n/locales/en.json | 1 + .../views/x/wizard/XWizardFinished.test.ts | 79 +++++++++++++++++++ .../src/views/x/wizard/XWizardFinished.vue | 20 +++++ 3 files changed, 100 insertions(+) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2a427f53..27caa520 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -372,6 +372,7 @@ "youJustSaved": "You just saved:", "youJustArchived": "You just archived:", "youJustDeleted": "You just deleted:", + "youJustTombstoned": "You just tombstoned your account:", "youJustMigrated": "You just migrated:" }, "premium": { diff --git a/src/renderer/src/views/x/wizard/XWizardFinished.test.ts b/src/renderer/src/views/x/wizard/XWizardFinished.test.ts index 95ec7c89..427a3415 100644 --- a/src/renderer/src/views/x/wizard/XWizardFinished.test.ts +++ b/src/renderer/src/views/x/wizard/XWizardFinished.test.ts @@ -236,6 +236,85 @@ describe("XWizardFinished", () => { }); }); + describe("basic rendering - tombstone mode", () => { + it("should render finished page for tombstone mode", async () => { + const { getJobsType } = await import("../../../util"); + (getJobsType as ReturnType).mockReturnValue("tombstone"); + + const mockModel = createMockModel( + { + xAccount: { + tombstoneUpdateBanner: true, + tombstoneUpdateBio: true, + tombstoneLockAccount: true, + }, + }, + {}, + ); + + wrapper = mount(XWizardFinished, { + props: { + model: mockModel as XViewModel, + failureStateIndexTweets_FailedToRetryAfterRateLimit: false, + failureStateIndexLikes_FailedToRetryAfterRateLimit: false, + }, + global: { + plugins: [i18n], + config: { + globalProperties: { + emitter: mockEmitter, + } as Record, + }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(wrapper.text()).toContain("You just tombstoned your account:"); + expect(wrapper.text()).toContain("Updated your banner"); + expect(wrapper.text()).toContain("Updated your bio"); + expect(wrapper.text()).toContain("Locked your account"); + }); + + it("should list only the tombstone actions that were turned on", async () => { + const { getJobsType } = await import("../../../util"); + (getJobsType as ReturnType).mockReturnValue("tombstone"); + + const mockModel = createMockModel( + { + xAccount: { + tombstoneUpdateBanner: false, + tombstoneUpdateBio: true, + tombstoneLockAccount: false, + }, + }, + {}, + ); + + wrapper = mount(XWizardFinished, { + props: { + model: mockModel as XViewModel, + failureStateIndexTweets_FailedToRetryAfterRateLimit: false, + failureStateIndexLikes_FailedToRetryAfterRateLimit: false, + }, + global: { + plugins: [i18n], + config: { + globalProperties: { + emitter: mockEmitter, + } as Record, + }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(wrapper.text()).toContain("Updated your bio"); + expect(wrapper.text()).not.toContain("Updated your banner"); + expect(wrapper.text()).not.toContain("Locked your account"); + }); + }); + describe("basic rendering - archive mode", () => { it("should render finished page for archive mode", async () => { const { getJobsType } = await import("../../../util"); diff --git a/src/renderer/src/views/x/wizard/XWizardFinished.vue b/src/renderer/src/views/x/wizard/XWizardFinished.vue index 187970c3..6513f5f9 100644 --- a/src/renderer/src/views/x/wizard/XWizardFinished.vue +++ b/src/renderer/src/views/x/wizard/XWizardFinished.vue @@ -301,6 +301,26 @@ onMounted(async () => { +
+
+

{{ t("finished.youJustTombstoned") }}

+
    +
  • + + Updated your banner +
  • +
  • + + Updated your bio +
  • +
  • + + Locked your account +
  • +
+
+
+

{{ t("finished.youJustMigrated") }}

From 7e1c20048c22e17619102ea0ffcc3b23479457d2 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 12:09:47 -0700 Subject: [PATCH 05/10] Save the bio the tombstone wrote, not the one it replaced 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) --- .../XViewModel/jobs_tombstone.test.ts | 24 +++++++++++++++++++ .../view_models/XViewModel/jobs_tombstone.ts | 9 +++++++ 2 files changed, 33 insertions(+) diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts index a0bf11c9..7fb0b4b5 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts @@ -237,6 +237,30 @@ describe("jobs_tombstone.ts", () => { expect(vm.error).not.toHaveBeenCalled(); }); + it("should save the new bio, so the wizard stops offering the old one", async () => { + // The tombstone page pre-fills from the bio Cyd has saved, which is + // written at login and nowhere else. Leaving it stale meant coming back + // to the page and being offered the bio the tombstone had replaced. + vm.account.xAccount!.bio = "Seeded test account."; + answerWith([], ["Gone to Bluesky"]); + + const result = await TombstoneJobs.runJobTombstoneUpdateBio(vm, 0); + + expect(result).toBe(true); + expect(vm.account.xAccount!.bio).toBe("Gone to Bluesky"); + expect(window.electron.database.saveAccount).toHaveBeenCalled(); + }); + + it("should leave the saved bio alone when the change did not land", async () => { + vm.account.xAccount!.bio = "Seeded test account."; + answerWith([], ["Seeded test account."]); + + const result = await TombstoneJobs.runJobTombstoneUpdateBio(vm, 0); + + expect(result).toBe(false); + expect(vm.account.xAccount!.bio).toBe("Seeded test account."); + }); + it("should report a bio that did not change rather than finishing", async () => { answerWith([], ["Seeded test account."]); diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts index ce999bcf..786fa76d 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts @@ -269,6 +269,15 @@ export async function runJobTombstoneUpdateBio( return false; } + // Cyd's saved copy of the bio is written at login and nowhere else, and the + // tombstone page pre-fills from it. Left stale, that page comes back + // offering the bio this job just replaced. The value read back off X is the + // one worth keeping, since it is what X actually holds. + if (vm.account.xAccount) { + vm.account.xAccount.bio = savedBio; + await window.electron.database.saveAccount(JSON.stringify(vm.account)); + } + await vm.finishJob(jobIndex); return true; } From 0dbd9a1d384ed41be2198aa7d700072e1c11a83c Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 12:14:59 -0700 Subject: [PATCH 06/10] Take X's crop step when it is offered, not always MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../XViewModel/jobs_tombstone.test.ts | 35 ++++++++++++++++++ .../view_models/XViewModel/jobs_tombstone.ts | 36 ++++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts index 7fb0b4b5..251d6704 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts @@ -89,6 +89,7 @@ describe("jobs_tombstone.ts", () => { expect(vm.waitForSelector).toHaveBeenCalledWith( '[data-testid="applyButton"]', "https://x.com/settings/profile", + 8000, ); expect(vm.scriptClickElement).toHaveBeenCalledWith( '[data-testid="applyButton"]', @@ -163,6 +164,40 @@ describe("jobs_tombstone.ts", () => { expect(events[0].type).toBe("change"); }); + it("should save the banner when X does not open its crop step", async () => { + // X only sometimes offers the crop. Seen on 2026-09-15: the probe got it + // three times out of three, and a later run got the banner dropped + // straight into the dialog ready to save. Waiting for a crop that never + // comes turned a run that was about to succeed into an error report. + const { TimeoutError } = await import("../automation_failures"); + vi.spyOn(vm, "waitForSelector").mockImplementation( + async (selector: string) => { + if (selector === '[data-testid="applyButton"]') { + throw new TimeoutError(selector); + } + }, + ); + answerWith( + [ + "https://pbs.twimg.com/profile_banners/1/before", + "https://pbs.twimg.com/profile_banners/1/after", + ], + [], + ); + + const result = await TombstoneJobs.runJobTombstoneUpdateBanner(vm, 0); + + expect(result).toBe(true); + expect(vm.scriptClickElement).not.toHaveBeenCalledWith( + '[data-testid="applyButton"]', + ); + expect(vm.scriptClickElement).toHaveBeenCalledWith( + 'button[data-testid="Profile_Save_Button"]', + ); + expect(vm.finishJob).toHaveBeenCalledWith(0); + expect(vm.error).not.toHaveBeenCalled(); + }); + it("should report a banner it could not set rather than claiming success", async () => { vi.spyOn(vm.getWebview()!, "executeJavaScript").mockResolvedValue(false); diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts index 786fa76d..004bf365 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts @@ -2,6 +2,7 @@ import type { XViewModel } from "./view_model"; import { PlausibleEvents } from "../../types"; import { AutomationErrorType } from "../../automation_errors"; import { tombstoneUpdateBioCreditCydText } from "./types"; +import { TimeoutError } from "../automation_failures"; const PROFILE_SETTINGS_URL = "https://x.com/settings/profile"; @@ -10,9 +11,13 @@ const PROFILE_SETTINGS_URL = "https://x.com/settings/profile"; // so it is reached by position among the matches rather than by the selector. const FILE_INPUT_SELECTOR = 'input[data-testid="fileInput"]'; -// The crop step X shows after a file is chosen +// The crop step X sometimes shows after a file is chosen const APPLY_BUTTON_SELECTOR = '[data-testid="applyButton"]'; +// Long enough for X to open its crop step, short enough that a run which is +// not going to get one is not held up waiting for it. +const CROP_STEP_TIMEOUT = 8000; + const SAVE_BUTTON_SELECTOR = 'button[data-testid="Profile_Save_Button"]'; const AUDIENCE_SETTINGS_URL = "https://x.com/settings/audience_and_tagging"; @@ -170,10 +175,31 @@ export async function runJobTombstoneUpdateBanner( return false; } - // Confirm the crop X offers, then save. Both clicks go through the element's - // own click(), because the dialog keeps a mask that swallows pointer events. - await vm.waitForSelector(APPLY_BUTTON_SELECTOR, PROFILE_SETTINGS_URL); - if (!(await vm.scriptClickElement(APPLY_BUTTON_SELECTOR))) { + // X does not always offer a crop. When it does not, the banner goes straight + // into the dialog ready to save, and waiting for an Apply button that is + // never coming turned a run that was about to succeed into a timeout and an + // error report. So take the crop when it is offered and carry on when it is + // not. What settles whether the banner landed is reading it back below, not + // which steps X put in the way. + // + // The click goes through the element's own click(), because the dialog keeps + // a mask that swallows pointer events. + let cropOffered = true; + try { + await vm.waitForSelector( + APPLY_BUTTON_SELECTOR, + PROFILE_SETTINGS_URL, + CROP_STEP_TIMEOUT, + ); + } catch (error) { + if (!(error instanceof TimeoutError)) { + throw error; + } + cropOffered = false; + vm.log("runJobTombstoneUpdateBanner", "X offered no crop step"); + } + + if (cropOffered && !(await vm.scriptClickElement(APPLY_BUTTON_SELECTOR))) { await vm.error( AutomationErrorType.x_runJob_tombstoneUpdateBanner_FailedToSave, { reason: "failed to click the crop's apply button" }, From c1fa7535309cff2a4e4169b21387f74def5e58b0 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 12:45:53 -0700 Subject: [PATCH 07/10] Look at the profile once it has drawn the banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../XViewModel/jobs_tombstone.test.ts | 41 +++++++++++++++++++ .../view_models/XViewModel/jobs_tombstone.ts | 37 +++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts index 251d6704..189b5d54 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts @@ -198,6 +198,47 @@ describe("jobs_tombstone.ts", () => { expect(vm.error).not.toHaveBeenCalled(); }); + it("should wait for the profile to render its banner before reading it", async () => { + // X renders the header photo after the load finishes. Reading straight + // away got "" from both the before and the after read, which match, so a + // banner that had been saved was reported as one that never changed. + answerWith( + ["https://pbs.twimg.com/old", "https://pbs.twimg.com/new"], + [], + ); + + await TombstoneJobs.runJobTombstoneUpdateBanner(vm, 0); + + expect(vm.waitForSelector).toHaveBeenCalledWith( + 'a[href$="/header_photo"] img', + "https://x.com/testuser", + 10000, + ); + }); + + it("should read a profile that renders no banner as having none", async () => { + // An account with no banner never grows that element, so the wait + // running out is an answer rather than something to throw over. + const { TimeoutError } = await import("../automation_failures"); + vi.spyOn(vm, "waitForSelector").mockImplementation( + async (selector: string) => { + if (selector === 'a[href$="/header_photo"] img') { + throw new TimeoutError(selector); + } + }, + ); + + const result = await TombstoneJobs.runJobTombstoneUpdateBanner(vm, 0); + + // Nothing was there before and nothing after, which is a real failure — + // but reported as one, rather than thrown out of the job. + expect(result).toBe(false); + expect(vm.error).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ reason: "the banner did not change" }), + ); + }); + it("should report a banner it could not set rather than claiming success", async () => { vi.spyOn(vm.getWebview()!, "executeJavaScript").mockResolvedValue(false); diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts index 004bf365..e5b0c0b5 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts @@ -20,6 +20,14 @@ const CROP_STEP_TIMEOUT = 8000; const SAVE_BUTTON_SELECTOR = 'button[data-testid="Profile_Save_Button"]'; +// The banner on the profile page, which is what says whether a save landed. +const BANNER_IMAGE_SELECTOR = 'a[href$="/header_photo"] img'; + +// How long to give the profile page to render its banner. An account with no +// banner never renders one, so running this out is an answer rather than a +// failure. +const BANNER_RENDER_TIMEOUT = 10000; + const AUDIENCE_SETTINGS_URL = "https://x.com/settings/audience_and_tagging"; // The "Protect your posts" box is the first checkbox on the audience settings @@ -54,13 +62,34 @@ async function clickSaveProfile( /** The banner X serves on the profile, which is the only answer that counts. */ async function readBannerURL(vm: XViewModel): Promise { - await vm.loadURLWithRateLimit( - `https://x.com/${vm.account.xAccount?.username ?? ""}`, - ); + const profileURL = `https://x.com/${vm.account.xAccount?.username ?? ""}`; + await vm.loadURLWithRateLimit(profileURL); + + // X renders the header photo after the page load finishes, so asking + // straight away gets nothing back and reads as "no banner". When both the + // before and the after read that way they match, and a banner that was in + // fact saved gets reported as one that never changed. + // + // A profile with no banner never grows this element, so running the timeout + // out is an answer, not a failure. + try { + await vm.waitForSelector( + BANNER_IMAGE_SELECTOR, + profileURL, + BANNER_RENDER_TIMEOUT, + ); + } catch (error) { + if (!(error instanceof TimeoutError)) { + throw error; + } + vm.log("readBannerURL", "the profile rendered no banner"); + return ""; + } + return ( (await vm.getWebview()?.executeJavaScript(` (() => { - const image = document.querySelector('a[href$="/header_photo"] img'); + const image = document.querySelector('${BANNER_IMAGE_SELECTOR}'); return image ? image.src : ""; })(); `)) ?? "" From c84b41d53e0f505faa4e7d6eb7a7073ed5020a6d Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 12:53:52 -0700 Subject: [PATCH 08/10] Watch the requests Cyd itself makes 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) --- src/account_x/x_account_controller.ts | 19 +++++ src/request_log.test.ts | 111 ++++++++++++++++++++++++++ src/request_log.ts | 76 ++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 src/request_log.test.ts create mode 100644 src/request_log.ts diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index 0aba486f..eba56b9c 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -9,6 +9,7 @@ import { Agent } from "@atproto/api"; import { Record as BskyPostRecord } from "@atproto/api/dist/client/types/app/bsky/feed/post"; import { getAccountDataPath } from "../util"; +import { RequestLog } from "../request_log"; import { XAccount, XJob, @@ -82,6 +83,8 @@ export class XAccountController extends BaseAccountController { private blueskyService: BlueskyService | null = null; + private requestLog: RequestLog; + constructor(accountID: number, mitmController: IMITMController) { super(accountID, mitmController); // Initialize progress with X-specific type @@ -89,7 +92,23 @@ export class XAccountController extends BaseAccountController { // Monitor web request metadata for X-specific functionality const ses = session.fromPartition(`persist:account-${this.accountID}`); + + // Off unless CYD_REQUEST_LOG says otherwise. See src/request_log.ts. + this.requestLog = new RequestLog(this.accountID); + if (this.requestLog.enabled) { + log.info( + `XAccountController: recording requests for account ${this.accountID} to ${process.env.CYD_REQUEST_LOG}`, + ); + } + ses.webRequest.onCompleted((details) => { + this.requestLog.record({ + method: details.method, + url: details.url, + statusCode: details.statusCode, + fromCache: details.fromCache, + }); + // Learn the operation identifiers X is using right now this.observeGraphqlOperation(details.url); diff --git a/src/request_log.test.ts b/src/request_log.test.ts new file mode 100644 index 00000000..c96832fe --- /dev/null +++ b/src/request_log.test.ts @@ -0,0 +1,111 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { describe, test, expect, beforeEach, afterEach } from "vitest"; + +import { RequestLog } from "./request_log"; + +describe("RequestLog", () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "cyd-request-log-")); + }); + + afterEach(() => { + delete process.env.CYD_REQUEST_LOG; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test("records nothing unless the environment asks for it", () => { + const requestLog = new RequestLog(1); + + expect(requestLog.enabled).toBe(false); + + requestLog.record({ + method: "POST", + url: "https://upload.x.com/i/media/upload.json", + statusCode: 201, + fromCache: false, + }); + + expect(fs.readdirSync(dir)).toEqual([]); + }); + + test("appends one line per request, so a crash keeps what came before", () => { + process.env.CYD_REQUEST_LOG = dir; + const requestLog = new RequestLog(7); + + expect(requestLog.enabled).toBe(true); + + requestLog.record({ + method: "POST", + url: "https://upload.x.com/i/media/upload.json", + statusCode: 201, + fromCache: false, + }); + requestLog.record({ + method: "POST", + url: "https://api.x.com/1.1/account/update_profile_banner.json", + statusCode: 200, + fromCache: false, + }); + + const lines = fs + .readFileSync(path.join(dir, "account-7.jsonl"), "utf8") + .trim() + .split("\n"); + + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0])).toMatchObject({ + method: "POST", + url: "https://upload.x.com/i/media/upload.json", + statusCode: 201, + }); + expect(JSON.parse(lines[1])).toMatchObject({ + url: "https://api.x.com/1.1/account/update_profile_banner.json", + statusCode: 200, + }); + expect(JSON.parse(lines[0]).at).toEqual(expect.any(String)); + }); + + test("keeps one account's traffic out of another's", () => { + process.env.CYD_REQUEST_LOG = dir; + + new RequestLog(1).record({ + method: "GET", + url: "https://x.com/one", + statusCode: 200, + fromCache: false, + }); + new RequestLog(2).record({ + method: "GET", + url: "https://x.com/two", + statusCode: 200, + fromCache: false, + }); + + expect(fs.readdirSync(dir).sort()).toEqual([ + "account-1.jsonl", + "account-2.jsonl", + ]); + }); + + test("a directory it cannot write to stops the log, not the run", () => { + process.env.CYD_REQUEST_LOG = path.join(dir, "file-in-the-way"); + fs.writeFileSync(path.join(dir, "file-in-the-way"), ""); + + const requestLog = new RequestLog(1); + + expect(requestLog.enabled).toBe(false); + expect(() => + requestLog.record({ + method: "GET", + url: "https://x.com/", + statusCode: 200, + fromCache: false, + }), + ).not.toThrow(); + }); +}); diff --git a/src/request_log.ts b/src/request_log.ts new file mode 100644 index 00000000..6b22760d --- /dev/null +++ b/src/request_log.ts @@ -0,0 +1,76 @@ +/** + * Records the requests a platform's session makes, so a run that has already + * failed can still be worked out afterwards. + * + * The capture tooling in `scripts/x-capture/` answers questions by driving a + * separate Chromium, which means its findings are about Chromium. When the app + * behaves differently from the probe — a banner that uploads under Playwright + * and does not under Electron — there is nothing to compare, because only one + * of the two is being watched. This watches the other one. + * + * Off unless `CYD_REQUEST_LOG` names a directory to write to: + * + * CYD_REQUEST_LOG=/tmp/cyd-requests npm start + * + * What it records is the traffic of a signed-in session, so it stays where + * whoever is debugging put it. It is never attached to an error report and + * never leaves the machine. To keep it that way it holds no bodies, no + * headers, and no cookies — a method, a URL, and a status say whether a call + * was made and what came back, which is the question worth asking, and none of + * them carry the session. + */ + +import fs from "fs"; +import path from "path"; + +import log from "electron-log/main"; + +export interface RequestLogEntry { + method: string; + url: string; + statusCode: number; + fromCache: boolean; +} + +export class RequestLog { + private logPath: string | null = null; + + constructor(accountID: number) { + const dir = process.env.CYD_REQUEST_LOG; + if (dir === undefined || dir.trim() === "") { + return; + } + + try { + fs.mkdirSync(dir, { recursive: true }); + this.logPath = path.join(dir, `account-${accountID}.jsonl`); + } catch (error) { + // A debugging aid that cannot write is worth a line in the log and + // nothing more. It must not take the run down with it. + log.error("RequestLog: could not open the request log:", error); + } + } + + get enabled(): boolean { + return this.logPath !== null; + } + + /** + * Appended a line at a time, because the runs worth reading this back for + * are the ones that did not reach the end. + */ + record(entry: RequestLogEntry): void { + if (this.logPath === null) { + return; + } + + try { + fs.appendFileSync( + this.logPath, + `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, + ); + } catch (error) { + log.error("RequestLog: could not record a request:", error); + } + } +} From 517c62bc6b54c975c02a023afee2bc9b1d62bf09 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 12:59:29 -0700 Subject: [PATCH 09/10] Wait for the profile to turn the banner over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../XViewModel/jobs_tombstone.test.ts | 21 ++++++ .../view_models/XViewModel/jobs_tombstone.ts | 73 ++++++++++++++++--- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts index 189b5d54..c2769271 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts @@ -239,6 +239,27 @@ describe("jobs_tombstone.ts", () => { ); }); + it("should wait out the old banner the profile shows before the new one", async () => { + // Recorded on a real run: the save returns 200, then the profile page + // fetches the banner it already had and replaces it 200ms later. Reading + // once catches the old URL and calls a save that worked unchanged, which + // is why this failed four times and then passed with nothing altered. + answerWith( + [ + "https://pbs.twimg.com/profile_banners/1/1789501624/1080x360", + "https://pbs.twimg.com/profile_banners/1/1789501624/1080x360", + "https://pbs.twimg.com/profile_banners/1/1789502153/1080x360", + ], + [], + ); + + const result = await TombstoneJobs.runJobTombstoneUpdateBanner(vm, 0); + + expect(result).toBe(true); + expect(vm.finishJob).toHaveBeenCalledWith(0); + expect(vm.error).not.toHaveBeenCalled(); + }); + it("should report a banner it could not set rather than claiming success", async () => { vi.spyOn(vm.getWebview()!, "executeJavaScript").mockResolvedValue(false); diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts index e5b0c0b5..379152d7 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts @@ -28,6 +28,12 @@ const BANNER_IMAGE_SELECTOR = 'a[href$="/header_photo"] img'; // failure. const BANNER_RENDER_TIMEOUT = 10000; +// The page draws the banner it already had and swaps the new one in a moment +// later, so the banner is read until it turns over rather than sampled once. +// Recorded on a real run, the swap landed 200ms after the first paint. +const BANNER_CHANGE_TRIES = 30; +const BANNER_POLL_INTERVAL = 500; + const AUDIENCE_SETTINGS_URL = "https://x.com/settings/audience_and_tagging"; // The "Protect your posts" box is the first checkbox on the audience settings @@ -61,7 +67,25 @@ async function clickSaveProfile( } /** The banner X serves on the profile, which is the only answer that counts. */ -async function readBannerURL(vm: XViewModel): Promise { +/** The banner the page is showing at this instant. */ +async function readBannerSrc(vm: XViewModel): Promise { + return ( + (await vm.getWebview()?.executeJavaScript(` + (() => { + const image = document.querySelector('${BANNER_IMAGE_SELECTOR}'); + return image ? image.src : ""; + })(); + `)) ?? "" + ); +} + +/** + * Opens the profile and waits for it to draw a banner at all. + * + * Returns false when it never draws one, which is what an account with no + * banner looks like and is an answer rather than a failure. + */ +async function openProfileWithBanner(vm: XViewModel): Promise { const profileURL = `https://x.com/${vm.account.xAccount?.username ?? ""}`; await vm.loadURLWithRateLimit(profileURL); @@ -82,18 +106,45 @@ async function readBannerURL(vm: XViewModel): Promise { if (!(error instanceof TimeoutError)) { throw error; } - vm.log("readBannerURL", "the profile rendered no banner"); + vm.log("openProfileWithBanner", "the profile rendered no banner"); + return false; + } + return true; +} + +async function readBannerURL(vm: XViewModel): Promise { + if (!(await openProfileWithBanner(vm))) { return ""; } + return await readBannerSrc(vm); +} - return ( - (await vm.getWebview()?.executeJavaScript(` - (() => { - const image = document.querySelector('${BANNER_IMAGE_SELECTOR}'); - return image ? image.src : ""; - })(); - `)) ?? "" - ); +/** + * Reads the banner back after a save, giving the page time to turn it over. + * + * The profile draws the banner it already had and replaces it a moment later, + * so asking once catches the old URL and reports a save that worked as one + * that changed nothing. Watching until it differs makes the answer the page + * settles on the one that counts, rather than whichever it happened to be + * showing when asked. + */ +async function readBannerURLAfterSave( + vm: XViewModel, + bannerBefore: string, +): Promise { + if (!(await openProfileWithBanner(vm))) { + return ""; + } + + let latest = ""; + for (let attempt = 0; attempt < BANNER_CHANGE_TRIES; attempt++) { + latest = await readBannerSrc(vm); + if (latest !== bannerBefore) { + return latest; + } + await vm.sleep(BANNER_POLL_INTERVAL); + } + return latest; } /** The bio X serves back into the profile dialog. */ @@ -247,7 +298,7 @@ export async function runJobTombstoneUpdateBanner( // Read the banner back. A save that clicked cleanly and changed nothing is // exactly the failure this job kept reporting as success. - const bannerAfter = await readBannerURL(vm); + const bannerAfter = await readBannerURLAfterSave(vm, bannerBefore); if (bannerAfter === bannerBefore) { vm.log("runJobTombstoneUpdateBanner", ["banner unchanged", bannerBefore]); await vm.error( From ed37f5fce423f6c56614ec635f5d560db0fb9221 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 15 Sep 2026 13:04:27 -0700 Subject: [PATCH 10/10] Put the banner on again when X offers no crop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../XViewModel/jobs_tombstone.test.ts | 93 ++++++++++++------- .../view_models/XViewModel/jobs_tombstone.ts | 90 +++++++++++------- 2 files changed, 117 insertions(+), 66 deletions(-) diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts index c2769271..0c259ae4 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.test.ts @@ -164,40 +164,6 @@ describe("jobs_tombstone.ts", () => { expect(events[0].type).toBe("change"); }); - it("should save the banner when X does not open its crop step", async () => { - // X only sometimes offers the crop. Seen on 2026-09-15: the probe got it - // three times out of three, and a later run got the banner dropped - // straight into the dialog ready to save. Waiting for a crop that never - // comes turned a run that was about to succeed into an error report. - const { TimeoutError } = await import("../automation_failures"); - vi.spyOn(vm, "waitForSelector").mockImplementation( - async (selector: string) => { - if (selector === '[data-testid="applyButton"]') { - throw new TimeoutError(selector); - } - }, - ); - answerWith( - [ - "https://pbs.twimg.com/profile_banners/1/before", - "https://pbs.twimg.com/profile_banners/1/after", - ], - [], - ); - - const result = await TombstoneJobs.runJobTombstoneUpdateBanner(vm, 0); - - expect(result).toBe(true); - expect(vm.scriptClickElement).not.toHaveBeenCalledWith( - '[data-testid="applyButton"]', - ); - expect(vm.scriptClickElement).toHaveBeenCalledWith( - 'button[data-testid="Profile_Save_Button"]', - ); - expect(vm.finishJob).toHaveBeenCalledWith(0); - expect(vm.error).not.toHaveBeenCalled(); - }); - it("should wait for the profile to render its banner before reading it", async () => { // X renders the header photo after the load finishes. Reading straight // away got "" from both the before and the after read, which match, so a @@ -260,6 +226,65 @@ describe("jobs_tombstone.ts", () => { expect(vm.error).not.toHaveBeenCalled(); }); + it("should put the banner on again when X offers no crop step", async () => { + // Recorded from Cyd's own session: with no crop step the image still + // uploads, INIT through FINALIZE, and update_profile_banner.json is + // never called at all. Apply is what stages the upload as the banner, + // so a run that never sees one saves nothing. + const { TimeoutError } = await import("../automation_failures"); + let cropWaits = 0; + vi.spyOn(vm, "waitForSelector").mockImplementation( + async (selector: string) => { + if (selector === '[data-testid="applyButton"]') { + cropWaits += 1; + if (cropWaits === 1) { + throw new TimeoutError(selector); + } + } + }, + ); + answerWith( + ["https://pbs.twimg.com/old", "https://pbs.twimg.com/new"], + [], + ); + + const result = await TombstoneJobs.runJobTombstoneUpdateBanner(vm, 0); + + expect(cropWaits).toBeGreaterThan(1); + expect(vm.scriptClickElement).toHaveBeenCalledWith( + '[data-testid="applyButton"]', + ); + expect(result).toBe(true); + expect(vm.error).not.toHaveBeenCalled(); + }); + + it("should say the banner was never staged rather than save without it", async () => { + const { TimeoutError } = await import("../automation_failures"); + vi.spyOn(vm, "waitForSelector").mockImplementation( + async (selector: string) => { + if (selector === '[data-testid="applyButton"]') { + throw new TimeoutError(selector); + } + }, + ); + answerWith(["https://pbs.twimg.com/old"], []); + + const result = await TombstoneJobs.runJobTombstoneUpdateBanner(vm, 0); + + expect(result).toBe(false); + // Clicking Save without the crop uploads the image and attaches nothing, + // then reports a banner that did not change, which explains none of it. + expect(vm.scriptClickElement).not.toHaveBeenCalledWith( + 'button[data-testid="Profile_Save_Button"]', + ); + expect(vm.error).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + reason: "X never offered its crop step, so the banner was not staged", + }), + ); + }); + it("should report a banner it could not set rather than claiming success", async () => { vi.spyOn(vm.getWebview()!, "executeJavaScript").mockResolvedValue(false); diff --git a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts index 379152d7..a01c8600 100644 --- a/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts +++ b/src/renderer/src/view_models/XViewModel/jobs_tombstone.ts @@ -14,10 +14,14 @@ const FILE_INPUT_SELECTOR = 'input[data-testid="fileInput"]'; // The crop step X sometimes shows after a file is chosen const APPLY_BUTTON_SELECTOR = '[data-testid="applyButton"]'; -// Long enough for X to open its crop step, short enough that a run which is -// not going to get one is not held up waiting for it. +// Long enough for X to open its crop step, short enough that a delivery which +// did not produce one can be made again without a long wait first. const CROP_STEP_TIMEOUT = 8000; +// How many times to put the banner on the file input before giving up on X +// offering a crop for it. +const BANNER_DELIVERY_TRIES = 3; + const SAVE_BUTTON_SELECTOR = 'button[data-testid="Profile_Save_Button"]'; // The banner on the profile page, which is what says whether a save landed. @@ -242,44 +246,66 @@ export async function runJobTombstoneUpdateBanner( // Load the profile page await vm.loadURLWithRateLimit(PROFILE_SETTINGS_URL); - // Wait for the file input, and set the banner on it + // Put the banner on the file input, and wait for X to offer its crop. + // + // The crop is not decoration that X sometimes shows: Apply is what stages + // the uploaded image as the banner. Recorded from Cyd's own session, a + // delivery that draws no crop still uploads the file — INIT, APPEND and + // FINALIZE all succeed — and then update_profile_banner.json is never called + // at all, so Save writes the name and bio and leaves the banner alone. + // + // A delivery that draws no crop has therefore staged nothing, and is worth + // making again rather than carrying on from. await vm.waitForSelector(FILE_INPUT_SELECTOR, PROFILE_SETTINGS_URL); - const wasSet = await vm - .getWebview() - ?.executeJavaScript(setBannerScript(bannerDataURL)); - if (!wasSet) { + + let cropOffered = false; + for ( + let attempt = 0; + attempt < BANNER_DELIVERY_TRIES && !cropOffered; + attempt++ + ) { + const wasSet = await vm + .getWebview() + ?.executeJavaScript(setBannerScript(bannerDataURL)); + if (!wasSet) { + await vm.error( + AutomationErrorType.x_runJob_tombstoneUpdateBanner_FailedToSetBanner, + {}, + ); + return false; + } + + try { + await vm.waitForSelector( + APPLY_BUTTON_SELECTOR, + PROFILE_SETTINGS_URL, + CROP_STEP_TIMEOUT, + ); + cropOffered = true; + } catch (error) { + if (!(error instanceof TimeoutError)) { + throw error; + } + vm.log("runJobTombstoneUpdateBanner", [ + "X offered no crop step, putting the banner on again", + attempt + 1, + ]); + } + } + + if (!cropOffered) { + // Saving from here uploads the image, attaches nothing, and comes back + // reporting a banner that did not change, which explains none of it. await vm.error( - AutomationErrorType.x_runJob_tombstoneUpdateBanner_FailedToSetBanner, - {}, + AutomationErrorType.x_runJob_tombstoneUpdateBanner_FailedToSave, + { reason: "X never offered its crop step, so the banner was not staged" }, ); return false; } - // X does not always offer a crop. When it does not, the banner goes straight - // into the dialog ready to save, and waiting for an Apply button that is - // never coming turned a run that was about to succeed into a timeout and an - // error report. So take the crop when it is offered and carry on when it is - // not. What settles whether the banner landed is reading it back below, not - // which steps X put in the way. - // // The click goes through the element's own click(), because the dialog keeps // a mask that swallows pointer events. - let cropOffered = true; - try { - await vm.waitForSelector( - APPLY_BUTTON_SELECTOR, - PROFILE_SETTINGS_URL, - CROP_STEP_TIMEOUT, - ); - } catch (error) { - if (!(error instanceof TimeoutError)) { - throw error; - } - cropOffered = false; - vm.log("runJobTombstoneUpdateBanner", "X offered no crop step"); - } - - if (cropOffered && !(await vm.scriptClickElement(APPLY_BUTTON_SELECTOR))) { + if (!(await vm.scriptClickElement(APPLY_BUTTON_SELECTOR))) { await vm.error( AutomationErrorType.x_runJob_tombstoneUpdateBanner_FailedToSave, { reason: "failed to click the crop's apply button" },