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', () => {
`;
- 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', () => {
`;
- 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);
});