From 059be42acd8952d6644c87ad94aec4ffd63c9d93 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Wed, 5 Aug 2026 17:55:34 +0900 Subject: [PATCH] fix(twitter): fail typed when a write command does not go through --- clis/twitter/block.js | 8 ++-- clis/twitter/block.test.js | 58 +++++++++++++++++++++++++++ clis/twitter/bookmark.js | 8 ++-- clis/twitter/bookmark.test.js | 15 ++++--- clis/twitter/delete.js | 8 ++-- clis/twitter/delete.test.js | 15 ++++--- clis/twitter/follow.js | 8 ++-- clis/twitter/follow.test.js | 56 ++++++++++++++++++++++++++ clis/twitter/hide-reply.js | 8 ++-- clis/twitter/hide-reply.test.js | 15 ++++--- clis/twitter/like.js | 8 ++-- clis/twitter/like.test.js | 23 +++++++---- clis/twitter/post.js | 25 ++++++++---- clis/twitter/post.test.js | 70 ++++++++++++++++++++++++++------- clis/twitter/quote.js | 20 +++++++--- clis/twitter/quote.test.js | 35 ++++++++++++----- clis/twitter/reply.js | 14 +++++-- clis/twitter/reply.test.js | 57 ++++++++++++++++++++++++--- clis/twitter/retweet.js | 8 ++-- clis/twitter/retweet.test.js | 12 +++--- clis/twitter/unblock.js | 8 ++-- clis/twitter/unblock.test.js | 57 +++++++++++++++++++++++++++ clis/twitter/unbookmark.js | 8 ++-- clis/twitter/unbookmark.test.js | 15 ++++--- clis/twitter/unfollow.js | 8 ++-- clis/twitter/unfollow.test.js | 57 +++++++++++++++++++++++++++ clis/twitter/unlike.js | 8 ++-- clis/twitter/unlike.test.js | 15 ++++--- clis/twitter/unretweet.js | 8 ++-- clis/twitter/unretweet.test.js | 12 +++--- 30 files changed, 521 insertions(+), 146 deletions(-) create mode 100644 clis/twitter/block.test.js create mode 100644 clis/twitter/follow.test.js create mode 100644 clis/twitter/unblock.test.js create mode 100644 clis/twitter/unfollow.test.js diff --git a/clis/twitter/block.js b/clis/twitter/block.js index ded29e0e7..104fa3840 100644 --- a/clis/twitter/block.js +++ b/clis/twitter/block.js @@ -80,10 +80,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the profile in the browser and retry.'); + } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/block.test.js b/clis/twitter/block.test.js new file mode 100644 index 000000000..967370638 --- /dev/null +++ b/clis/twitter/block.test.js @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './block.js'; +import { createPageMock } from '../test-utils.js'; + +describe('twitter block command', () => { + it('navigates to the profile URL and reports success when the block script confirms', async () => { + const cmd = getRegistry().get('twitter/block'); + expect(cmd?.func).toBeTypeOf('function'); + const page = createPageMock([ + { ok: true, message: 'Successfully blocked @alice.' }, + ]); + const result = await cmd.func(page, { + username: 'alice', + }); + expect(page.goto).toHaveBeenCalledWith('https://x.com/alice'); + expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' }); + expect(page.wait).toHaveBeenNthCalledWith(2, 2); + const script = page.evaluate.mock.calls[0][0]; + // Idempotency probe: when already blocking ([data-testid$="-unblock"] present), + // the script returns ok:true with an "already blocking" message. + expect(script).toContain('[data-testid$="-unblock"]'); + expect(script).toContain('[data-testid="userActions"]'); + expect(script).toContain("includes('Block')"); + expect(script).toContain('blockItem.click()'); + expect(script).toContain('[data-testid="confirmationSheetConfirm"]'); + expect(result).toEqual([ + { status: 'success', message: 'Successfully blocked @alice.' }, + ]); + }); + + it('typed-fails without re-waiting when the block script reports a UI mismatch', async () => { + const cmd = getRegistry().get('twitter/block'); + const page = createPageMock([ + { + ok: false, + message: 'Could not find user actions menu. Are you logged in?', + }, + ]); + await expect(cmd.func(page, { + username: 'alice', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find user actions menu. Are you logged in?', + }); + expect(page.wait).toHaveBeenCalledTimes(1); + }); + + it('throws CommandExecutionError when no page is provided', async () => { + const cmd = getRegistry().get('twitter/block'); + await expect(cmd.func(undefined, { + username: 'alice', + })).rejects.toThrow(CommandExecutionError); + }); +}); diff --git a/clis/twitter/bookmark.js b/clis/twitter/bookmark.js index c23f66776..61279ff2c 100644 --- a/clis/twitter/bookmark.js +++ b/clis/twitter/bookmark.js @@ -64,10 +64,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); + } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/bookmark.test.js b/clis/twitter/bookmark.test.js index c08852b40..c2a8ea5a4 100644 --- a/clis/twitter/bookmark.test.js +++ b/clis/twitter/bookmark.test.js @@ -35,7 +35,7 @@ describe('twitter bookmark command', () => { ]); }); - it('returns a failed row without re-waiting when the bookmark script reports a UI mismatch', async () => { + it('typed-fails without re-waiting when the bookmark script reports a UI mismatch', async () => { const cmd = getRegistry().get('twitter/bookmark'); const page = createPageMock([ { @@ -43,15 +43,14 @@ describe('twitter bookmark command', () => { message: 'Could not find Bookmark button on the requested tweet. Are you logged in?', }, ]); - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find Bookmark button on the requested tweet. Are you logged in?', }); - expect(result).toEqual([ - { - status: 'failed', - message: 'Could not find Bookmark button on the requested tweet. Are you logged in?', - }, - ]); expect(page.wait).toHaveBeenCalledTimes(1); }); diff --git a/clis/twitter/delete.js b/clis/twitter/delete.js index 28c54dadc..cf09986f2 100644 --- a/clis/twitter/delete.js +++ b/clis/twitter/delete.js @@ -84,12 +84,12 @@ cli({ await page.goto(target.url); await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely const result = unwrapBrowserResult(await page.evaluate(buildDeleteScript(target.id))); - if (result.ok) { - // Wait for the deletion request to be processed - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/delete.test.js b/clis/twitter/delete.test.js index 1798f995c..6d032867e 100644 --- a/clis/twitter/delete.test.js +++ b/clis/twitter/delete.test.js @@ -49,7 +49,7 @@ describe('twitter delete command', () => { }, ]); }); - it('passes through matched-tweet lookup failures', async () => { + it('typed-fails on matched-tweet lookup failures', async () => { const cmd = getRegistry().get('twitter/delete'); expect(cmd?.func).toBeTypeOf('function'); const page = { @@ -60,15 +60,14 @@ describe('twitter delete command', () => { message: 'Could not find the tweet card matching the requested URL.', }), }; - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find the tweet card matching the requested URL.', }); - expect(result).toEqual([ - { - status: 'failed', - message: 'Could not find the tweet card matching the requested URL.', - }, - ]); expect(page.wait).toHaveBeenCalledTimes(1); }); it('unwraps Browser Bridge evaluate envelopes before checking delete success', async () => { diff --git a/clis/twitter/follow.js b/clis/twitter/follow.js index 5bf99ff3d..f18e8b754 100644 --- a/clis/twitter/follow.js +++ b/clis/twitter/follow.js @@ -57,10 +57,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the profile in the browser and retry.'); + } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/follow.test.js b/clis/twitter/follow.test.js new file mode 100644 index 000000000..8b2866245 --- /dev/null +++ b/clis/twitter/follow.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './follow.js'; +import { createPageMock } from '../test-utils.js'; + +describe('twitter follow command', () => { + it('navigates to the profile URL and reports success when the follow script confirms', async () => { + const cmd = getRegistry().get('twitter/follow'); + expect(cmd?.func).toBeTypeOf('function'); + const page = createPageMock([ + { ok: true, message: 'Successfully followed @alice.' }, + ]); + const result = await cmd.func(page, { + username: 'alice', + }); + expect(page.goto).toHaveBeenCalledWith('https://x.com/alice'); + expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' }); + expect(page.wait).toHaveBeenNthCalledWith(2, 2); + const script = page.evaluate.mock.calls[0][0]; + // Idempotency probe: when already following ([data-testid$="-unfollow"] present), + // the script returns ok:true with an "already following" message. + expect(script).toContain('[data-testid$="-unfollow"]'); + expect(script).toContain('[data-testid$="-follow"]'); + expect(script).toContain('followBtn.click()'); + expect(result).toEqual([ + { status: 'success', message: 'Successfully followed @alice.' }, + ]); + }); + + it('typed-fails without re-waiting when the follow script reports a UI mismatch', async () => { + const cmd = getRegistry().get('twitter/follow'); + const page = createPageMock([ + { + ok: false, + message: 'Could not find Follow button. Are you logged in?', + }, + ]); + await expect(cmd.func(page, { + username: 'alice', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find Follow button. Are you logged in?', + }); + expect(page.wait).toHaveBeenCalledTimes(1); + }); + + it('throws CommandExecutionError when no page is provided', async () => { + const cmd = getRegistry().get('twitter/follow'); + await expect(cmd.func(undefined, { + username: 'alice', + })).rejects.toThrow(CommandExecutionError); + }); +}); diff --git a/clis/twitter/hide-reply.js b/clis/twitter/hide-reply.js index d538413cf..279248616 100644 --- a/clis/twitter/hide-reply.js +++ b/clis/twitter/hide-reply.js @@ -77,10 +77,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); + } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/hide-reply.test.js b/clis/twitter/hide-reply.test.js index 6014c0bff..ff7a38f33 100644 --- a/clis/twitter/hide-reply.test.js +++ b/clis/twitter/hide-reply.test.js @@ -37,7 +37,7 @@ describe('twitter hide-reply command', () => { ]); }); - it('returns a failed row without re-waiting when the hide-reply script reports a UI mismatch', async () => { + it('typed-fails without re-waiting when the hide-reply script reports a UI mismatch', async () => { const cmd = getRegistry().get('twitter/hide-reply'); const page = createPageMock([ { @@ -45,15 +45,14 @@ describe('twitter hide-reply command', () => { message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.', }, ]); - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.', }); - expect(result).toEqual([ - { - status: 'failed', - message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.', - }, - ]); expect(page.wait).toHaveBeenCalledTimes(1); }); diff --git a/clis/twitter/like.js b/clis/twitter/like.js index 8f18cc84b..50cdaff20 100644 --- a/clis/twitter/like.js +++ b/clis/twitter/like.js @@ -68,12 +68,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) { - // Wait for the like network request to be processed - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/like.test.js b/clis/twitter/like.test.js index 7a8692bef..dab079efc 100644 --- a/clis/twitter/like.test.js +++ b/clis/twitter/like.test.js @@ -33,7 +33,15 @@ describe('twitter like command', () => { ]); }); - it('returns a failed row without re-waiting when the like script reports a UI mismatch', async () => { + it('keeps an already-liked tweet a success rather than a failure', async () => { + const cmd = getRegistry().get('twitter/like'); + const page = createPageMock([{ ok: true, message: 'Tweet is already liked.' }]); + + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161' })) + .resolves.toEqual([{ status: 'success', message: 'Tweet is already liked.' }]); + }); + + it('typed-fails without re-waiting when the like script reports a UI mismatch', async () => { const cmd = getRegistry().get('twitter/like'); const page = createPageMock([ { @@ -41,15 +49,14 @@ describe('twitter like command', () => { message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?', }, ]); - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?', }); - expect(result).toEqual([ - { - status: 'failed', - message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?', - }, - ]); // Only the primaryColumn wait should run when ok is false. expect(page.wait).toHaveBeenCalledTimes(1); }); diff --git a/clis/twitter/post.js b/clis/twitter/post.js index b437472d7..cdac3678e 100644 --- a/clis/twitter/post.js +++ b/clis/twitter/post.js @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { cli, Strategy } from '@jackwener/opencli/registry'; -import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; import { unwrapBrowserResult } from './shared.js'; import { isRecoverableFileInputError } from './utils.js'; @@ -282,7 +282,7 @@ async function submitTweet(page, text) { // postcondition: X may close/rewrite the modal after failed submits. // Require a fresh success toast so the evidence is tied to this click. } - return { ok: false, message: 'Tweet submission did not complete before timeout.' }; + return { ok: false, unconfirmed: true, message: 'Tweet submission did not complete before timeout.' }; })()`), 'Twitter post completion'); validateSubmitStatusPair(result); return result; @@ -332,7 +332,7 @@ cli({ } const uploadState = await waitForImageUpload(page, absPaths.length); if (!uploadState?.ok) { - return [{ status: 'failed', message: uploadState?.message ?? `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text }]; + throw new TimeoutError('twitter image upload', UPLOAD_TIMEOUT_MS / 1000, 'Nothing was posted. Retry, or attach a smaller image.'); } } @@ -340,17 +340,26 @@ cli({ // the final Draft.js composer state immediately before clicking Post. const typeResult = await insertComposerText(page, text); if (!typeResult?.ok) { - return [{ status: 'failed', message: typeResult?.message ?? 'Could not type tweet text.', text }]; + throw new CommandExecutionError(typeResult?.message ?? 'Could not type tweet text.', 'Open the composer in the browser and check whether X is asking you to log in.'); } await page.wait(1); const result = await submitTweet(page, text); + if (result?.unconfirmed) { + // The poll expiring does not mean the tweet stayed in the composer, + // so this must not read as a definite failure: the agent workflow + // retries CommandExecutionError and would post twice (#2255). + throw new TimeoutError('twitter post', SUBMIT_TIMEOUT_MS / 1000, `${result.message} Check \`opencli twitter tweets --limit 1\` before retrying; the post may already be live.`); + } + if (!result?.ok) { + throw new CommandExecutionError(result?.message ?? 'Tweet failed to post.', 'Nothing was posted. Open the composer in the browser and retry.'); + } return [{ - status: result?.ok ? 'success' : 'failed', - message: result?.message ?? 'Tweet failed to post.', + status: 'success', + message: result.message, text, - ...(result?.id ? { id: result.id } : {}), - ...(result?.url ? { url: result.url } : {}), + ...(result.id ? { id: result.id } : {}), + ...(result.url ? { url: result.url } : {}), }]; } }); diff --git a/clis/twitter/post.test.js b/clis/twitter/post.test.js index 6de0c091d..e0029f595 100644 --- a/clis/twitter/post.test.js +++ b/clis/twitter/post.test.js @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; import { getRegistry } from '@jackwener/opencli/registry'; import './post.js'; @@ -95,15 +96,13 @@ describe('twitter post command', () => { }]); }); - it('returns failed when text area not found', async () => { + it('typed-fails when text area not found', async () => { const command = getCommand(); const page = makePage([ { ok: false, message: 'Could not find the tweet composer text area. Are you logged in?' }, ]); - const result = await command.func(page, { text: 'hello' }); - - expect(result).toEqual([{ status: 'failed', message: 'Could not find the tweet composer text area. Are you logged in?', text: 'hello' }]); + await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError); expect(page.insertText).not.toHaveBeenCalled(); }); @@ -250,9 +249,12 @@ describe('twitter post command', () => { it('does not report success from a cleared composer and a timeline permalink', async () => { const timelineOnly = '
someone else
'; - await expect(runPostAgainstDom(timelineOnly, 'cleared composer')).resolves.toEqual([ - { status: 'failed', message: 'Tweet submission did not complete before timeout.', text: 'cleared composer' }, - ]); + await expect(runPostAgainstDom(timelineOnly, 'cleared composer')).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + hint: expect.stringContaining('Tweet submission did not complete before timeout.'), + }); }); it('keeps the permalink that the success toast carries', async () => { @@ -288,9 +290,12 @@ describe('twitter post command', () => {
Your post was sent. View
`; - await expect(runPostAgainstDom(oldToast, 'old toast')).resolves.toEqual([ - { status: 'failed', message: 'Tweet submission did not complete before timeout.', text: 'old toast' }, - ]); + await expect(runPostAgainstDom(oldToast, 'old toast')).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + hint: expect.stringContaining('Tweet submission did not complete before timeout.'), + }); }); it('unwraps Browser Bridge envelopes for action results', async () => { @@ -405,18 +410,55 @@ describe('twitter post command', () => { expect(submitScript).toContain('data-opencli-before-submit-toast'); }); - it('returns failed when image upload times out', async () => { + it('typed-fails when image upload times out', async () => { const command = getCommand(); const page = makePage([ { ok: false, message: 'Image upload timed out (30s).' }, ]); - const result = await command.func(page, { text: 'timeout', images: 'a.png' }); - - expect(result).toEqual([{ status: 'failed', message: 'Image upload timed out (30s).', text: 'timeout' }]); + await expect(command.func(page, { text: 'timeout', images: 'a.png' })).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + }); expect(page.insertText).not.toHaveBeenCalled(); }); + it('typed-fails with a non-zero exit code when the post never goes out', async () => { + const command = getCommand(); + const page = makePage([ + { ok: true }, // focus composer + { ok: true }, // verify native insertText + { ok: true }, // click post + { ok: false, message: 'Tweet button is disabled or not found.' }, + ]); + + await expect(command.func(page, { text: 'never sent' })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Tweet button is disabled or not found.', + hint: expect.stringContaining('Nothing was posted'), + }); + }); + + it('reports an unconfirmed submit as temporary, without claiming nothing was posted', async () => { + const command = getCommand(); + const page = makePage([ + { ok: true }, // focus composer + { ok: true }, // verify native insertText + { ok: true }, // click post + { ok: false, unconfirmed: true, message: 'Tweet submission did not complete before timeout.' }, + ]); + + await expect(command.func(page, { text: 'unconfirmed' })).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + hint: expect.stringContaining('may already be live'), + }); + }); + it('falls back to DOM insertion when native insertText is unavailable', async () => { const command = getCommand(); const page = makePage([ diff --git a/clis/twitter/quote.js b/clis/twitter/quote.js index 5e6fd5589..8a63e05ae 100644 --- a/clis/twitter/quote.js +++ b/clis/twitter/quote.js @@ -1,5 +1,5 @@ import * as fs from 'node:fs'; -import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js'; import { @@ -9,6 +9,9 @@ import { resolveImagePath, } from './utils.js'; +const SUBMIT_POLL_MS = 500; +const SUBMIT_TIMEOUT_MS = 15_000; + function buildQuoteComposerUrl(url) { // Twitter/X quote-tweet compose URL: the `url` param attaches the source // tweet as a quoted card. Validating tweet-id shape early surfaces obvious @@ -18,6 +21,7 @@ function buildQuoteComposerUrl(url) { } async function submitQuote(page, text, tweetId) { + const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS); return page.evaluate(`(async () => { try { ${buildTwitterArticleScopeSource(tweetId)} @@ -79,8 +83,8 @@ async function submitQuote(page, text, tweetId) { const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); const expectedText = normalize(textToInsert); - for (let i = 0; i < 30; i++) { - await new Promise(r => setTimeout(r, 500)); + for (let i = 0; i < ${JSON.stringify(iterations)}; i++) { + await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)})); const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]')) .filter((el) => visible(el)); const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || '')); @@ -94,7 +98,7 @@ async function submitQuote(page, text, tweetId) { ); if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' }; } - return { ok: false, message: 'Quote tweet submission did not complete before timeout.' }; + return { ok: false, unconfirmed: true, message: 'Quote tweet submission did not complete before timeout.' }; } catch (e) { return { ok: false, message: e.toString() }; } @@ -152,8 +156,14 @@ cli({ // Wait for network submission to complete await page.wait(3); } + if (result.unconfirmed) { + throw new TimeoutError('twitter quote', SUBMIT_TIMEOUT_MS / 1000, `${result.message} Check your profile before retrying; the quote may already be live.`); + } + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing was posted. Open the tweet in the browser and retry.'); + } return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message, text: kwargs.text, ...(kwargs.image ? { image: kwargs.image } : {}), diff --git a/clis/twitter/quote.test.js b/clis/twitter/quote.test.js index 7c92bb76b..695ca245d 100644 --- a/clis/twitter/quote.test.js +++ b/clis/twitter/quote.test.js @@ -152,27 +152,42 @@ describe('twitter quote command', () => { })).rejects.toThrow(CommandExecutionError); }); - it('returns a failed row when the quote target fails to render', async () => { + it('typed-fails when the quote target fails to render', async () => { const cmd = getRegistry().get('twitter/quote'); expect(cmd?.func).toBeTypeOf('function'); const page = createPageMock([ { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' }, ]); - const result = await cmd.func(page, { + + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', text: 'orphaned quote', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + hint: expect.stringContaining('Nothing was posted'), }); - expect(result).toEqual([ - { - status: 'failed', - message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.', - text: 'orphaned quote', - }, - ]); - // Only the textarea wait should run when ok is false (no extra 3s post-submit wait). expect(page.wait).toHaveBeenCalledTimes(1); }); + it('reports an unconfirmed quote as temporary, without claiming nothing was posted', async () => { + const cmd = getRegistry().get('twitter/quote'); + const page = createPageMock([ + { ok: false, unconfirmed: true, message: 'Quote tweet submission did not complete before timeout.' }, + ]); + + await expect(cmd.func(page, { + url: 'https://x.com/alice/status/2040254679301718161', + text: 'unconfirmed', + })).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + hint: expect.stringContaining('may already be live'), + }); + }); + it('throws CommandExecutionError when no page is provided', async () => { const cmd = getRegistry().get('twitter/quote'); await expect(cmd.func(undefined, { diff --git a/clis/twitter/reply.js b/clis/twitter/reply.js index d930c8434..8812528ed 100644 --- a/clis/twitter/reply.js +++ b/clis/twitter/reply.js @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { parseTweetUrl, unwrapBrowserResult } from './shared.js'; import { @@ -214,7 +214,7 @@ async function waitForReplySent(page, text) { // Composer disappearance or text clearing alone can happen after // modal rewrites or failed submits. Require a fresh success toast. } - return { ok: false, message: 'Reply submission did not complete before timeout.' }; + return { ok: false, unconfirmed: true, message: 'Reply submission did not complete before timeout.' }; })()`), 'Twitter reply completion'); validateReplyStatusUrl(result); return result; @@ -280,15 +280,21 @@ cli({ // back to the target tweet's visible Reply action. const composer = await openReplyComposer(page, kwargs.url); if (!composer?.ok) { - return [{ status: 'failed', message: composer?.message ?? 'Could not open the reply composer.', text: kwargs.text }]; + throw new CommandExecutionError(composer?.message ?? 'Could not open the reply composer.', 'Open the tweet in the browser and check whether the reply box is available.'); } if (localImagePath) { await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 }); await attachComposerImage(page, localImagePath); } const result = await submitReply(page, kwargs.text); + if (result.unconfirmed) { + throw new TimeoutError('twitter reply', SUBMIT_TIMEOUT_MS / 1000, `${result.message} Check the tweet before retrying; the reply may already be live.`); + } + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing was posted. Open the tweet in the browser and retry.'); + } return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message, text: kwargs.text, ...(result.url ? { url: result.url } : {}), diff --git a/clis/twitter/reply.test.js b/clis/twitter/reply.test.js index 940388737..159ca80ae 100644 --- a/clis/twitter/reply.test.js +++ b/clis/twitter/reply.test.js @@ -32,6 +32,45 @@ describe('twitter reply command', () => { }, ]); }); + it('typed-fails when the reply never leaves the composer', async () => { + const cmd = getRegistry().get('twitter/reply'); + const page = createPageMock([ + { ok: true }, + { ok: true }, + { ok: false, message: 'Reply button is disabled or not found.' }, + ]); + + await expect(cmd.func(page, { + url: 'https://x.com/_kop6/status/2040254679301718161', + text: 'never sent', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Reply button is disabled or not found.', + hint: expect.stringContaining('Nothing was posted'), + }); + }); + + it('reports an unconfirmed reply as temporary, without claiming nothing was posted', async () => { + const cmd = getRegistry().get('twitter/reply'); + const page = createPageMock([ + { ok: true }, + { ok: true }, + { ok: false, unconfirmed: true, message: 'Reply submission did not complete before timeout.' }, + ]); + + await expect(cmd.func(page, { + url: 'https://x.com/_kop6/status/2040254679301718161', + text: 'unconfirmed', + })).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + hint: expect.stringContaining('may already be live'), + }); + }); + it('uploads a local image through the dedicated reply composer when --image is provided', async () => { const cmd = getRegistry().get('twitter/reply'); expect(cmd?.func).toBeTypeOf('function'); @@ -195,9 +234,12 @@ describe('twitter reply command', () => { }; it('does not report reply success from a cleared composer without a fresh toast', async () => { - await expect(runReplyAgainstDom('', 'cleared reply')).resolves.toEqual([ - { status: 'failed', message: 'Reply submission did not complete before timeout.', text: 'cleared reply' }, - ]); + await expect(runReplyAgainstDom('', 'cleared reply')).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + hint: expect.stringContaining('Reply submission did not complete before timeout.'), + }); }); it('ignores a reply success toast that existed before clicking Reply', async () => { @@ -205,9 +247,12 @@ describe('twitter reply command', () => {
Your post was sent. View
`; - await expect(runReplyAgainstDom(oldToast, 'old reply toast')).resolves.toEqual([ - { status: 'failed', message: 'Reply submission did not complete before timeout.', text: 'old reply toast' }, - ]); + await expect(runReplyAgainstDom(oldToast, 'old reply toast')).rejects.toMatchObject({ + name: 'TimeoutError', + code: 'TIMEOUT', + exitCode: 75, + hint: expect.stringContaining('Reply submission did not complete before timeout.'), + }); }); it('returns the permalink from a fresh reply success toast only', async () => { diff --git a/clis/twitter/retweet.js b/clis/twitter/retweet.js index 2ad0ea258..0d322455b 100644 --- a/clis/twitter/retweet.js +++ b/clis/twitter/retweet.js @@ -82,12 +82,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) { - // Wait for the retweet network request to be processed - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/retweet.test.js b/clis/twitter/retweet.test.js index d1810c138..7a3041bdc 100644 --- a/clis/twitter/retweet.test.js +++ b/clis/twitter/retweet.test.js @@ -39,18 +39,20 @@ describe('twitter retweet command', () => { ]); }); - it('returns a failed row when the confirm menu item never appears', async () => { + it('typed-fails when the confirm menu item never appears', async () => { const cmd = getRegistry().get('twitter/retweet'); expect(cmd?.func).toBeTypeOf('function'); const page = createPageMock([ { ok: false, message: 'Retweet menu opened but the confirm option did not appear.' }, ]); - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Retweet menu opened but the confirm option did not appear.', }); - expect(result).toEqual([ - { status: 'failed', message: 'Retweet menu opened but the confirm option did not appear.' }, - ]); expect(page.wait).toHaveBeenCalledTimes(1); }); diff --git a/clis/twitter/unblock.js b/clis/twitter/unblock.js index d2d252f93..38242cdd1 100644 --- a/clis/twitter/unblock.js +++ b/clis/twitter/unblock.js @@ -63,10 +63,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the profile in the browser and retry.'); + } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/unblock.test.js b/clis/twitter/unblock.test.js new file mode 100644 index 000000000..15ecc8ea0 --- /dev/null +++ b/clis/twitter/unblock.test.js @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './unblock.js'; +import { createPageMock } from '../test-utils.js'; + +describe('twitter unblock command', () => { + it('navigates to the profile URL and reports success when the unblock script confirms', async () => { + const cmd = getRegistry().get('twitter/unblock'); + expect(cmd?.func).toBeTypeOf('function'); + const page = createPageMock([ + { ok: true, message: 'Successfully unblocked @alice.' }, + ]); + const result = await cmd.func(page, { + username: 'alice', + }); + expect(page.goto).toHaveBeenCalledWith('https://x.com/alice'); + expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' }); + expect(page.wait).toHaveBeenNthCalledWith(2, 2); + const script = page.evaluate.mock.calls[0][0]; + // Idempotency probe: when the Follow button is visible ([data-testid$="-follow"] + // present, so not blocked), the script returns ok:true with an "already unblocked" message. + expect(script).toContain('[data-testid$="-follow"]'); + expect(script).toContain('[data-testid$="-unblock"]'); + expect(script).toContain('unblockBtn.click()'); + expect(script).toContain('[data-testid="confirmationSheetConfirm"]'); + expect(result).toEqual([ + { status: 'success', message: 'Successfully unblocked @alice.' }, + ]); + }); + + it('typed-fails without re-waiting when the unblock script reports a UI mismatch', async () => { + const cmd = getRegistry().get('twitter/unblock'); + const page = createPageMock([ + { + ok: false, + message: 'Could not find Unblock button. Are you logged in?', + }, + ]); + await expect(cmd.func(page, { + username: 'alice', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find Unblock button. Are you logged in?', + }); + expect(page.wait).toHaveBeenCalledTimes(1); + }); + + it('throws CommandExecutionError when no page is provided', async () => { + const cmd = getRegistry().get('twitter/unblock'); + await expect(cmd.func(undefined, { + username: 'alice', + })).rejects.toThrow(CommandExecutionError); + }); +}); diff --git a/clis/twitter/unbookmark.js b/clis/twitter/unbookmark.js index 1b5b58853..77bbc08c9 100644 --- a/clis/twitter/unbookmark.js +++ b/clis/twitter/unbookmark.js @@ -61,10 +61,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); + } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/unbookmark.test.js b/clis/twitter/unbookmark.test.js index 1c50827bf..daab3012e 100644 --- a/clis/twitter/unbookmark.test.js +++ b/clis/twitter/unbookmark.test.js @@ -34,7 +34,7 @@ describe('twitter unbookmark command', () => { ]); }); - it('returns a failed row without re-waiting when the unbookmark script reports a UI mismatch', async () => { + it('typed-fails without re-waiting when the unbookmark script reports a UI mismatch', async () => { const cmd = getRegistry().get('twitter/unbookmark'); const page = createPageMock([ { @@ -42,15 +42,14 @@ describe('twitter unbookmark command', () => { message: 'Could not find Remove Bookmark button on the requested tweet. Are you logged in?', }, ]); - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find Remove Bookmark button on the requested tweet. Are you logged in?', }); - expect(result).toEqual([ - { - status: 'failed', - message: 'Could not find Remove Bookmark button on the requested tweet. Are you logged in?', - }, - ]); expect(page.wait).toHaveBeenCalledTimes(1); }); diff --git a/clis/twitter/unfollow.js b/clis/twitter/unfollow.js index 8fddf5a85..c91e22461 100644 --- a/clis/twitter/unfollow.js +++ b/clis/twitter/unfollow.js @@ -63,10 +63,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the profile in the browser and retry.'); + } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/unfollow.test.js b/clis/twitter/unfollow.test.js new file mode 100644 index 000000000..d000e0238 --- /dev/null +++ b/clis/twitter/unfollow.test.js @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './unfollow.js'; +import { createPageMock } from '../test-utils.js'; + +describe('twitter unfollow command', () => { + it('navigates to the profile URL and reports success when the unfollow script confirms', async () => { + const cmd = getRegistry().get('twitter/unfollow'); + expect(cmd?.func).toBeTypeOf('function'); + const page = createPageMock([ + { ok: true, message: 'Successfully unfollowed @alice.' }, + ]); + const result = await cmd.func(page, { + username: 'alice', + }); + expect(page.goto).toHaveBeenCalledWith('https://x.com/alice'); + expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' }); + expect(page.wait).toHaveBeenNthCalledWith(2, 2); + const script = page.evaluate.mock.calls[0][0]; + // Idempotency probe: when the Follow button is visible ([data-testid$="-follow"] + // present, so not following), the script returns ok:true with an "already unfollowed" message. + expect(script).toContain('[data-testid$="-follow"]'); + expect(script).toContain('[data-testid$="-unfollow"]'); + expect(script).toContain('unfollowBtn.click()'); + expect(script).toContain('[data-testid="confirmationSheetConfirm"]'); + expect(result).toEqual([ + { status: 'success', message: 'Successfully unfollowed @alice.' }, + ]); + }); + + it('typed-fails without re-waiting when the unfollow script reports a UI mismatch', async () => { + const cmd = getRegistry().get('twitter/unfollow'); + const page = createPageMock([ + { + ok: false, + message: 'Could not find Unfollow button. Are you logged in?', + }, + ]); + await expect(cmd.func(page, { + username: 'alice', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find Unfollow button. Are you logged in?', + }); + expect(page.wait).toHaveBeenCalledTimes(1); + }); + + it('throws CommandExecutionError when no page is provided', async () => { + const cmd = getRegistry().get('twitter/unfollow'); + await expect(cmd.func(undefined, { + username: 'alice', + })).rejects.toThrow(CommandExecutionError); + }); +}); diff --git a/clis/twitter/unlike.js b/clis/twitter/unlike.js index d0f0516d5..1ef5be32a 100644 --- a/clis/twitter/unlike.js +++ b/clis/twitter/unlike.js @@ -68,12 +68,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) { - // Wait for the unlike network request to be processed - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/unlike.test.js b/clis/twitter/unlike.test.js index b421eba2e..3f6490250 100644 --- a/clis/twitter/unlike.test.js +++ b/clis/twitter/unlike.test.js @@ -34,7 +34,7 @@ describe('twitter unlike command', () => { ]); }); - it('returns a failed row without re-waiting when the unlike script reports a UI mismatch', async () => { + it('typed-fails without re-waiting when the unlike script reports a UI mismatch', async () => { const cmd = getRegistry().get('twitter/unlike'); expect(cmd?.func).toBeTypeOf('function'); const page = createPageMock([ @@ -43,15 +43,14 @@ describe('twitter unlike command', () => { message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?', }, ]); - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?', }); - expect(result).toEqual([ - { - status: 'failed', - message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?', - }, - ]); // Only the primaryColumn wait should run when ok is false. expect(page.wait).toHaveBeenCalledTimes(1); }); diff --git a/clis/twitter/unretweet.js b/clis/twitter/unretweet.js index 2aed853c3..7dd83097a 100644 --- a/clis/twitter/unretweet.js +++ b/clis/twitter/unretweet.js @@ -82,12 +82,12 @@ cli({ return { ok: false, message: e.toString() }; } })()`); - if (result.ok) { - // Wait for the unretweet network request to be processed - await page.wait(2); + if (!result.ok) { + throw new CommandExecutionError(result.message, 'Nothing changed. Open the tweet in the browser and retry.'); } + await page.wait(2); return [{ - status: result.ok ? 'success' : 'failed', + status: 'success', message: result.message }]; } diff --git a/clis/twitter/unretweet.test.js b/clis/twitter/unretweet.test.js index f94fe8880..a7203bd2f 100644 --- a/clis/twitter/unretweet.test.js +++ b/clis/twitter/unretweet.test.js @@ -39,18 +39,20 @@ describe('twitter unretweet command', () => { ]); }); - it('returns a failed row when the confirm menu item never appears', async () => { + it('typed-fails when the confirm menu item never appears', async () => { const cmd = getRegistry().get('twitter/unretweet'); expect(cmd?.func).toBeTypeOf('function'); const page = createPageMock([ { ok: false, message: 'Unretweet menu opened but the confirm option did not appear.' }, ]); - const result = await cmd.func(page, { + await expect(cmd.func(page, { url: 'https://x.com/alice/status/2040254679301718161', + })).rejects.toMatchObject({ + name: 'CommandExecutionError', + code: 'COMMAND_EXEC', + exitCode: 1, + message: 'Unretweet menu opened but the confirm option did not appear.', }); - expect(result).toEqual([ - { status: 'failed', message: 'Unretweet menu opened but the confirm option did not appear.' }, - ]); expect(page.wait).toHaveBeenCalledTimes(1); });