From 9f24218e036970e4f368d8f491a6724064647592 Mon Sep 17 00:00:00 2001 From: Gaurav Saxena Date: Sun, 24 May 2026 23:34:12 +0530 Subject: [PATCH] feat(twitter): add scheduled post commands --- cli-manifest.json | 87 +++++++++ clis/twitter/schedule.js | 252 ++++++++++++++++++++++++++ clis/twitter/schedule.test.js | 86 +++++++++ clis/twitter/scheduled-delete.js | 105 +++++++++++ clis/twitter/scheduled-delete.test.js | 58 ++++++ clis/twitter/scheduled-list.js | 43 +++++ clis/twitter/scheduled-list.test.js | 36 ++++ clis/twitter/scheduled-utils.js | 29 +++ 8 files changed, 696 insertions(+) create mode 100644 clis/twitter/schedule.js create mode 100644 clis/twitter/schedule.test.js create mode 100644 clis/twitter/scheduled-delete.js create mode 100644 clis/twitter/scheduled-delete.test.js create mode 100644 clis/twitter/scheduled-list.js create mode 100644 clis/twitter/scheduled-list.test.js create mode 100644 clis/twitter/scheduled-utils.js diff --git a/cli-manifest.json b/cli-manifest.json index fd1a13229..086402563 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -30461,6 +30461,93 @@ "sourceFile": "twitter/retweet.js", "navigateBefore": true }, + { + "site": "twitter", + "name": "schedule", + "description": "Schedule a new X post through the web composer", + "access": "write", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "The text content of the scheduled post" + }, + { + "name": "at", + "type": "string", + "required": false, + "help": "Local scheduled time, e.g. \"2026-05-24 21:30\"" + }, + { + "name": "delay-minutes", + "type": "int", + "default": 10, + "required": false, + "help": "Schedule this many minutes from now" + } + ], + "columns": [ + "status", + "message", + "text", + "scheduledFor" + ], + "type": "js", + "modulePath": "twitter/schedule.js", + "sourceFile": "twitter/schedule.js", + "navigateBefore": true + }, + { + "site": "twitter", + "name": "scheduled-delete", + "description": "Delete a scheduled X post by matching a text fragment", + "access": "write", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "match", + "type": "string", + "required": true, + "positional": true, + "help": "Text fragment that uniquely identifies the scheduled post" + } + ], + "columns": [ + "status", + "message", + "match" + ], + "type": "js", + "modulePath": "twitter/scheduled-delete.js", + "sourceFile": "twitter/scheduled-delete.js", + "navigateBefore": true + }, + { + "site": "twitter", + "name": "scheduled-list", + "description": "List X posts currently scheduled in the web composer", + "access": "read", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "index", + "scheduledFor", + "text" + ], + "type": "js", + "modulePath": "twitter/scheduled-list.js", + "sourceFile": "twitter/scheduled-list.js", + "navigateBefore": true + }, { "site": "twitter", "name": "search", diff --git a/clis/twitter/schedule.js b/clis/twitter/schedule.js new file mode 100644 index 000000000..768302b5c --- /dev/null +++ b/clis/twitter/schedule.js @@ -0,0 +1,252 @@ +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; + +const COMPOSE_URL = 'https://x.com/compose/post'; +const DEFAULT_DELAY_MINUTES = 10; +const MIN_DELAY_MINUTES = 2; + +function parsePositiveInt(value, name) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new ArgumentError(`${name} must be a non-negative integer`); + } + return parsed; +} + +export function parseScheduleTarget(kwargs, now = new Date()) { + const at = String(kwargs.at ?? '').trim(); + const hasExplicitDelay = kwargs['delay-minutes'] !== undefined + && kwargs['delay-minutes'] !== null + && kwargs['delay-minutes'] !== '' + && Number(kwargs['delay-minutes']) !== DEFAULT_DELAY_MINUTES; + if (at && hasExplicitDelay) { + throw new ArgumentError('Use either --at or --delay-minutes, not both'); + } + + let target; + if (at) { + const normalized = at.includes('T') ? at : at.replace(' ', 'T'); + target = new Date(normalized); + if (Number.isNaN(target.getTime())) { + throw new ArgumentError('Invalid --at value. Use a local time like "2026-05-24 21:30"'); + } + } else { + const delayMinutes = kwargs['delay-minutes'] === undefined || kwargs['delay-minutes'] === null || kwargs['delay-minutes'] === '' + ? DEFAULT_DELAY_MINUTES + : parsePositiveInt(kwargs['delay-minutes'], 'delay-minutes'); + if (delayMinutes < MIN_DELAY_MINUTES) { + throw new ArgumentError(`delay-minutes must be at least ${MIN_DELAY_MINUTES}`); + } + target = new Date(now.getTime() + delayMinutes * 60_000); + } + + if (target.getTime() <= now.getTime() + 60_000) { + throw new ArgumentError('Scheduled time must be at least one minute in the future'); + } + + return { + year: target.getFullYear(), + month: target.getMonth() + 1, + day: target.getDate(), + hour: target.getHours(), + minute: target.getMinutes(), + iso: target.toISOString(), + }; +} + +async function focusComposer(page) { + return page.evaluate(`(() => { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')); + const box = boxes.find(visible) || boxes[0]; + if (!box) return { ok: false, message: 'Could not find the post composer text area. Are you logged in?' }; + box.focus(); + return { ok: true }; + })()`); +} + +async function verifyComposerText(page, text) { + return page.evaluate(`(async () => { + const expected = ${JSON.stringify(text)}; + const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + const normalizedExpected = normalize(expected); + for (let i = 0; i < 40; i++) { + const box = document.querySelector('[data-testid="tweetTextarea_0"]'); + const actual = box ? (box.innerText || box.textContent || '') : ''; + if (box && normalize(actual).includes(normalizedExpected)) return { ok: true }; + await new Promise(r => setTimeout(r, 250)); + } + const box = document.querySelector('[data-testid="tweetTextarea_0"]'); + return { + ok: false, + message: 'Could not verify post text in the composer after typing.', + actualText: box ? (box.innerText || box.textContent || '') : '' + }; + })()`); +} + +function isUnsupportedInsertTextError(err) { + const msg = err instanceof Error ? err.message : String(err); + const lower = msg.toLowerCase(); + return lower.includes('unknown action') || lower.includes('not supported') || lower.includes('inserttext returned no inserted flag'); +} + +async function insertComposerText(page, text) { + const focusResult = await focusComposer(page); + if (!focusResult?.ok) return focusResult; + + const nativeInserters = [ + page.nativeType?.bind(page), + page.insertText?.bind(page), + ].filter(Boolean); + + for (const insert of nativeInserters) { + try { + await insert(text); + const verified = await verifyComposerText(page, text); + if (verified?.ok) return verified; + } + catch (err) { + if (!isUnsupportedInsertTextError(err)) throw err; + } + } + + return page.evaluate(`(async () => { + try { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')); + const box = boxes.find(visible) || boxes[0]; + if (!box) return { ok: false, message: 'Could not find the post composer text area. Are you logged in?' }; + const textToInsert = ${JSON.stringify(text)}; + const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + box.focus(); + if (!document.execCommand('insertText', false, textToInsert)) { + const dt = new DataTransfer(); + dt.setData('text/plain', textToInsert); + box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + } + await new Promise(r => setTimeout(r, 500)); + const actual = box.innerText || box.textContent || ''; + if (normalize(actual).includes(normalize(textToInsert))) return { ok: true }; + return { ok: false, message: 'Could not verify post text in the composer after typing.', actualText: actual }; + } catch (e) { return { ok: false, message: String(e) }; } + })()`); +} + +async function setSchedule(page, target) { + const openResult = await page.evaluate(`(async () => { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const button = Array.from(document.querySelectorAll('[data-testid="scheduleOption"], button,[role="button"]')) + .find((el) => visible(el) && /schedule post/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))); + if (!button) return { ok: false, message: 'Could not find the Schedule post button.' }; + button.click(); + return { ok: true }; + })()`); + if (!openResult?.ok) return openResult; + + await page.wait({ selector: '[data-testid="scheduledConfirmationPrimaryAction"]', timeout: 15 }); + return page.evaluate(`(async () => { + try { + const target = ${JSON.stringify(target)}; + const selects = Array.from(document.querySelectorAll('select')); + if (selects.length < 5) { + return { ok: false, message: 'Could not find schedule date/time controls.' }; + } + + const values = [target.month, target.day, target.year, target.hour, target.minute]; + for (let i = 0; i < values.length; i++) { + const select = selects[i]; + select.value = String(values[i]); + select.dispatchEvent(new Event('input', { bubbles: true })); + select.dispatchEvent(new Event('change', { bubbles: true })); + } + + await new Promise(r => setTimeout(r, 500)); + const confirm = document.querySelector('[data-testid="scheduledConfirmationPrimaryAction"]'); + if (!confirm || confirm.disabled || confirm.getAttribute('aria-disabled') === 'true') { + return { ok: false, message: 'Schedule confirmation button is disabled or missing.' }; + } + confirm.click(); + return { ok: true }; + } catch (e) { + return { ok: false, message: String(e) }; + } + })()`); +} + +async function submitScheduledPost(page) { + const clickResult = await page.evaluate(`(async () => { + try { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const buttons = Array.from(document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')); + const btn = buttons.find((el) => visible(el) && /schedule/i.test(el.textContent || '') && !el.disabled && el.getAttribute('aria-disabled') !== 'true'); + if (!btn) return { ok: false, message: 'Schedule submit button is disabled or not found.' }; + btn.click(); + return { ok: true }; + } catch (e) { return { ok: false, message: String(e) }; } + })()`); + if (!clickResult?.ok) return clickResult; + + return page.evaluate(`(async () => { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + for (let i = 0; i < 40; i++) { + await new Promise(r => setTimeout(r, 500)); + const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]')).filter(visible); + const successToast = toasts.find((el) => /will be sent|scheduled/i.test(el.textContent || '')); + if (successToast) return { ok: true, message: (successToast.textContent || 'Post scheduled successfully.').trim() }; + const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || '')); + if (alert) return { ok: false, message: (alert.textContent || 'Scheduled post failed.').trim() }; + } + return { ok: false, message: 'Schedule submission did not complete before timeout.' }; + })()`); +} + +cli({ + site: 'twitter', + name: 'schedule', + access: 'write', + description: 'Schedule a new X post through the web composer', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the scheduled post' }, + { name: 'at', type: 'string', required: false, help: 'Local scheduled time, e.g. "2026-05-24 21:30"' }, + { name: 'delay-minutes', type: 'int', default: DEFAULT_DELAY_MINUTES, help: 'Schedule this many minutes from now' }, + ], + columns: ['status', 'message', 'text', 'scheduledFor'], + func: async (page, kwargs) => { + if (!page) throw new CommandExecutionError('Browser session required for twitter schedule'); + const text = String(kwargs.text ?? '').trim(); + if (!text) throw new ArgumentError('Scheduled post text is required'); + const target = parseScheduleTarget(kwargs); + + await page.goto(COMPOSE_URL, { waitUntil: 'load', settleMs: 2500 }); + await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 }); + + const scheduleResult = await setSchedule(page, target); + if (!scheduleResult?.ok) { + return [{ status: 'failed', message: scheduleResult?.message ?? 'Could not configure schedule.', text, scheduledFor: target.iso }]; + } + + await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 }); + const typeResult = await insertComposerText(page, text); + if (!typeResult?.ok) { + return [{ status: 'failed', message: typeResult?.message ?? 'Could not type scheduled post text.', text, scheduledFor: target.iso }]; + } + + await page.wait(1); + const result = await submitScheduledPost(page); + return [{ + status: result?.ok ? 'success' : 'failed', + message: result?.message ?? 'Scheduled post failed.', + text, + scheduledFor: target.iso, + }]; + } +}); + +export const __test__ = { + parseScheduleTarget, + setSchedule, +}; diff --git a/clis/twitter/schedule.test.js b/clis/twitter/schedule.test.js new file mode 100644 index 000000000..14d3b551a --- /dev/null +++ b/clis/twitter/schedule.test.js @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './schedule.js'; +import { __test__ } from './schedule.js'; + +function makePage(evaluateResults = [], overrides = {}) { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + evaluate.mockResolvedValue({ ok: true }); + + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate, + insertText: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('twitter schedule command', () => { + const getCommand = () => getRegistry().get('twitter/schedule'); + + it('registers schedule output columns', () => { + const command = getCommand(); + expect(command?.columns).toEqual(['status', 'message', 'text', 'scheduledFor']); + }); + + it('parses delay-minutes relative to the current time', () => { + const target = __test__.parseScheduleTarget({ 'delay-minutes': 15 }, new Date('2026-05-24T20:00:00')); + + expect(target).toMatchObject({ + year: 2026, + month: 5, + day: 24, + hour: 20, + minute: 15, + }); + }); + + it('schedules text through the current compose route', async () => { + const command = getCommand(); + const page = makePage([ + { ok: true }, // open schedule modal + { ok: true }, // set schedule controls and confirm + { ok: true }, // focus composer + { ok: true }, // verify inserted text + { ok: true }, // click Schedule + { ok: true, message: 'Your post will be sent on Thu, May 24, 2096 at 9:30 PM' }, + ]); + + const result = await command.func(page, { text: 'scheduled test', at: '2096-05-24 21:30', 'delay-minutes': '' }); + + expect(result).toEqual([{ + status: 'success', + message: 'Your post will be sent on Thu, May 24, 2096 at 9:30 PM', + text: 'scheduled test', + scheduledFor: new Date('2096-05-24T21:30').toISOString(), + }]); + expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post', { waitUntil: 'load', settleMs: 2500 }); + expect(page.insertText).toHaveBeenCalledWith('scheduled test'); + }); + + it('fails when schedule modal cannot be opened', async () => { + const command = getCommand(); + const page = makePage([ + { ok: false, message: 'Could not find the Schedule post button.' }, + ]); + + const result = await command.func(page, { text: 'scheduled test', at: '2096-05-24 21:30', 'delay-minutes': '' }); + + expect(result[0]).toMatchObject({ + status: 'failed', + message: 'Could not find the Schedule post button.', + text: 'scheduled test', + }); + expect(page.insertText).not.toHaveBeenCalled(); + }); + + it('rejects empty post text', async () => { + const command = getCommand(); + await expect(command.func(makePage(), { text: ' ' })).rejects.toBeInstanceOf(ArgumentError); + }); +}); diff --git a/clis/twitter/scheduled-delete.js b/clis/twitter/scheduled-delete.js new file mode 100644 index 000000000..7eeb260fa --- /dev/null +++ b/clis/twitter/scheduled-delete.js @@ -0,0 +1,105 @@ +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { openScheduledQueue } from './scheduled-utils.js'; + +function normalizeMatch(input) { + const match = String(input ?? '').trim(); + if (!match) { + throw new ArgumentError('Text fragment is required to delete a scheduled post'); + } + return match; +} + +export function buildScheduledDeleteScript(match) { + return `(async () => { + try { + const needle = ${JSON.stringify(match)}.toLowerCase(); + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim(); + const findItems = () => Array.from(document.querySelectorAll('[data-testid="unsentTweet"]')); + let items = findItems(); + const target = items.find((item) => normalize(item.querySelector('[data-testid="tweetText"]')?.textContent || item.textContent).toLowerCase().includes(needle)); + if (!target) { + return { ok: false, message: 'Could not find a scheduled post matching the text fragment.' }; + } + + const edit = Array.from(document.querySelectorAll('button,[role="button"]')) + .find((el) => visible(el) && normalize(el.textContent) === 'Edit'); + if (edit) { + edit.click(); + await new Promise(r => setTimeout(r, 800)); + } + + items = findItems(); + const editableTarget = items.find((item) => normalize(item.querySelector('[data-testid="tweetText"]')?.textContent || item.textContent).toLowerCase().includes(needle)); + if (!editableTarget) { + return { ok: false, message: 'Scheduled post disappeared before deletion.' }; + } + + const row = editableTarget.closest('button,[role="button"], [data-testid="cellInnerDiv"]') || editableTarget.parentElement; + const checkbox = row?.querySelector('[role="checkbox"], input[type="checkbox"]') + || editableTarget.parentElement?.querySelector('[role="checkbox"], input[type="checkbox"]'); + if (checkbox) { + checkbox.click(); + } else { + editableTarget.click(); + } + await new Promise(r => setTimeout(r, 800)); + + const deleteButton = Array.from(document.querySelectorAll('button,[role="button"]')) + .find((el) => visible(el) && /^delete$/i.test(normalize(el.textContent)) && el.getAttribute('aria-disabled') !== 'true' && !el.disabled); + if (!deleteButton) { + return { ok: false, message: 'Could not find enabled Delete button after selecting scheduled post.' }; + } + deleteButton.click(); + await new Promise(r => setTimeout(r, 800)); + + const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]') + || Array.from(document.querySelectorAll('button,[role="button"]')).find((el) => visible(el) && /^delete$/i.test(normalize(el.textContent))); + if (!confirm) { + return { ok: false, message: 'Delete confirmation dialog did not appear.' }; + } + confirm.click(); + await new Promise(r => setTimeout(r, 1200)); + + const stillExists = findItems().some((item) => normalize(item.querySelector('[data-testid="tweetText"]')?.textContent || item.textContent).toLowerCase().includes(needle)); + if (stillExists) { + return { ok: false, message: 'Scheduled post still appears after delete confirmation.' }; + } + return { ok: true, message: 'Scheduled post deleted.' }; + } catch (e) { + return { ok: false, message: String(e) }; + } + })()`; +} + +cli({ + site: 'twitter', + name: 'scheduled-delete', + access: 'write', + description: 'Delete a scheduled X post by matching a text fragment', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'match', type: 'string', required: true, positional: true, help: 'Text fragment that uniquely identifies the scheduled post' }, + ], + columns: ['status', 'message', 'match'], + func: async (page, kwargs) => { + if (!page) throw new CommandExecutionError('Browser session required for twitter scheduled-delete'); + const match = normalizeMatch(kwargs.match); + const opened = await openScheduledQueue(page); + if (!opened?.ok) throw new CommandExecutionError(opened?.message ?? 'Could not open scheduled posts queue'); + const result = await page.evaluate(buildScheduledDeleteScript(match)); + return [{ + status: result?.ok ? 'success' : 'failed', + message: result?.message ?? 'Scheduled post delete failed.', + match, + }]; + } +}); + +export const __test__ = { + buildScheduledDeleteScript, + normalizeMatch, +}; diff --git a/clis/twitter/scheduled-delete.test.js b/clis/twitter/scheduled-delete.test.js new file mode 100644 index 000000000..4d73908a4 --- /dev/null +++ b/clis/twitter/scheduled-delete.test.js @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './scheduled-delete.js'; + +function makePage(evaluateResults = [{ ok: true }, { ok: true }, { ok: true, message: 'Scheduled post deleted.' }]) { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate, + }; +} + +describe('twitter scheduled-delete command', () => { + const getCommand = () => getRegistry().get('twitter/scheduled-delete'); + + it('registers scheduled delete columns', () => { + const command = getCommand(); + expect(command?.columns).toEqual(['status', 'message', 'match']); + }); + + it('deletes a scheduled post by text fragment', async () => { + const command = getCommand(); + const page = makePage(); + + const result = await command.func(page, { match: 'scheduled test' }); + + expect(result).toEqual([{ status: 'success', message: 'Scheduled post deleted.', match: 'scheduled test' }]); + expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post', { waitUntil: 'load', settleMs: 2500 }); + expect(page.evaluate.mock.calls[2][0]).toContain('scheduled test'); + }); + + it('returns failed when the DOM script cannot delete the scheduled post', async () => { + const command = getCommand(); + const page = makePage([ + { ok: true }, + { ok: true }, + { ok: false, message: 'Could not find a scheduled post matching the text fragment.' }, + ]); + + const result = await command.func(page, { match: 'missing' }); + + expect(result).toEqual([{ + status: 'failed', + message: 'Could not find a scheduled post matching the text fragment.', + match: 'missing', + }]); + }); + + it('rejects an empty text fragment', async () => { + const command = getCommand(); + await expect(command.func(makePage(), { match: ' ' })).rejects.toBeInstanceOf(ArgumentError); + }); +}); diff --git a/clis/twitter/scheduled-list.js b/clis/twitter/scheduled-list.js new file mode 100644 index 000000000..3bd791792 --- /dev/null +++ b/clis/twitter/scheduled-list.js @@ -0,0 +1,43 @@ +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { openScheduledQueue } from './scheduled-utils.js'; + +export function buildScheduledListScript() { + return `(() => { + const items = Array.from(document.querySelectorAll('[data-testid="unsentTweet"]')); + return items.map((item, index) => { + const text = (item.querySelector('[data-testid="tweetText"]')?.textContent || '').replace(/\\s+/g, ' ').trim(); + const fullText = (item.textContent || '').replace(/\\s+/g, ' ').trim(); + let scheduledFor = fullText; + if (text && scheduledFor.endsWith(text)) scheduledFor = scheduledFor.slice(0, -text.length); + scheduledFor = scheduledFor.replace(/^\\s*Will send on\\s*/i, '').trim(); + return { + index: index + 1, + scheduledFor, + text, + }; + }); + })()`; +} + +cli({ + site: 'twitter', + name: 'scheduled-list', + access: 'read', + description: 'List X posts currently scheduled in the web composer', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['index', 'scheduledFor', 'text'], + func: async (page) => { + if (!page) throw new CommandExecutionError('Browser session required for twitter scheduled-list'); + const opened = await openScheduledQueue(page); + if (!opened?.ok) throw new CommandExecutionError(opened?.message ?? 'Could not open scheduled posts queue'); + return page.evaluate(buildScheduledListScript()); + } +}); + +export const __test__ = { + buildScheduledListScript, +}; diff --git a/clis/twitter/scheduled-list.test.js b/clis/twitter/scheduled-list.test.js new file mode 100644 index 000000000..b211e70cf --- /dev/null +++ b/clis/twitter/scheduled-list.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './scheduled-list.js'; + +function makePage(evaluateResults = []) { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate, + }; +} + +describe('twitter scheduled-list command', () => { + const getCommand = () => getRegistry().get('twitter/scheduled-list'); + + it('registers scheduled list columns', () => { + const command = getCommand(); + expect(command?.columns).toEqual(['index', 'scheduledFor', 'text']); + }); + + it('opens the scheduled queue and returns rows from the DOM script', async () => { + const command = getCommand(); + const rows = [{ index: 1, scheduledFor: 'Sun, May 24, 2026 at 9:30 PM', text: 'scheduled test' }]; + const page = makePage([{ ok: true }, { ok: true }, rows]); + + const result = await command.func(page, {}); + + expect(result).toEqual(rows); + expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post', { waitUntil: 'load', settleMs: 2500 }); + expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="unsentButton"]', timeout: 15 }); + }); +}); diff --git a/clis/twitter/scheduled-utils.js b/clis/twitter/scheduled-utils.js new file mode 100644 index 000000000..7c7b9637f --- /dev/null +++ b/clis/twitter/scheduled-utils.js @@ -0,0 +1,29 @@ +export const COMPOSE_URL = 'https://x.com/compose/post'; + +export async function openScheduledQueue(page) { + await page.goto(COMPOSE_URL, { waitUntil: 'load', settleMs: 2500 }); + await page.wait({ selector: '[data-testid="unsentButton"]', timeout: 15 }); + const opened = await page.evaluate(`(async () => { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const drafts = Array.from(document.querySelectorAll('[data-testid="unsentButton"], button,[role="button"]')) + .find((el) => visible(el) && /drafts/i.test(el.textContent || '')); + if (!drafts) return { ok: false, message: 'Could not find Drafts button in composer.' }; + drafts.click(); + return { ok: true }; + })()`); + if (!opened?.ok) return opened; + + await page.wait(1); + const selected = await page.evaluate(`(async () => { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const scheduledTab = Array.from(document.querySelectorAll('a, [role="tab"]')) + .find((el) => visible(el) && /^scheduled$/i.test((el.textContent || '').trim())); + if (!scheduledTab) return { ok: false, message: 'Could not find Scheduled tab in Drafts.' }; + scheduledTab.click(); + return { ok: true }; + })()`); + if (!selected?.ok) return selected; + + await page.wait(2); + return { ok: true }; +}