Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/clis/twitter/delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';

import { CommandExecutionError } from '../../errors.js';
import { getRegistry } from '../../registry.js';
import { __test__ } from './delete.js';
import './delete.js';

describe('twitter delete command', () => {
it('extracts tweet ids from both user and i/status URLs', () => {
expect(__test__.extractTweetId('https://x.com/alice/status/2040254679301718161?s=20')).toBe('2040254679301718161');
expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
});

it('targets the matched tweet article instead of the first More button on the page', async () => {
const cmd = getRegistry().get('twitter/delete');
expect(cmd?.func).toBeTypeOf('function');

const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({ ok: true, message: 'Tweet successfully deleted.' }),
};

const result = await cmd!.func!(page as any, {
url: 'https://x.com/alice/status/2040254679301718161?s=20',
});

expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161?s=20');
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
expect(page.wait).toHaveBeenNthCalledWith(2, 2);

const script = (page.evaluate as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(script).toContain("document.querySelectorAll('article')");
expect(script).toContain("'/status/' + tweetId");
expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')");

expect(result).toEqual([
{
status: 'success',
message: 'Tweet successfully deleted.',
},
]);
});

it('passes through matched-tweet lookup failures', async () => {
const cmd = getRegistry().get('twitter/delete');
expect(cmd?.func).toBeTypeOf('function');

const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({
ok: false,
message: 'Could not find the tweet card matching the requested URL.',
}),
};

const result = await cmd!.func!(page as any, {
url: 'https://x.com/alice/status/2040254679301718161',
});

expect(result).toEqual([
{
status: 'failed',
message: 'Could not find the tweet card matching the requested URL.',
},
]);
expect(page.wait).toHaveBeenCalledTimes(1);
});

it('normalizes invalid tweet URLs into CommandExecutionError', async () => {
const cmd = getRegistry().get('twitter/delete');
expect(cmd?.func).toBeTypeOf('function');

const page = {
goto: vi.fn(),
wait: vi.fn(),
evaluate: vi.fn(),
};

await expect(
cmd!.func!(page as any, {
url: 'https://x.com/alice/home',
}),
).rejects.toThrow(CommandExecutionError);

expect(page.goto).not.toHaveBeenCalled();
expect(page.wait).not.toHaveBeenCalled();
expect(page.evaluate).not.toHaveBeenCalled();
});
});
128 changes: 83 additions & 45 deletions src/clis/twitter/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,76 @@ import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';

function extractTweetId(url: string): string {
let pathname = '';
try {
pathname = new URL(url).pathname;
} catch {
throw new Error(`Invalid tweet URL: ${url}`);
}

const match = pathname.match(/\/status\/(\d+)/);
if (!match?.[1]) {
throw new Error(`Could not extract tweet ID from URL: ${url}`);
}

return match[1];
}

function buildDeleteScript(tweetId: string): string {
return `(async () => {
try {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const tweetId = ${JSON.stringify(tweetId)};
const targetArticle = Array.from(document.querySelectorAll('article')).find((article) =>
Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
try {
return new URL(link.href, window.location.origin).pathname.includes('/status/' + tweetId);
} catch {
return false;
}
})
);

if (!targetArticle) {
return { ok: false, message: 'Could not find the tweet card matching the requested URL.' };
}

const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]'));
const moreMenu = buttons.find((el) => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
if (!moreMenu) {
return { ok: false, message: 'Could not find the "More" context menu on the matched tweet. Are you sure you are logged in and looking at a valid tweet?' };
}

moreMenu.click();
await new Promise(r => setTimeout(r, 1000));

const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
const deleteBtn = items.find((item) => {
const text = (item.textContent || '').trim();
return text.includes('Delete') && !text.includes('List');
});

if (!deleteBtn) {
return { ok: false, message: 'The matched tweet menu did not contain Delete. This tweet may not belong to you.' };
}

deleteBtn.click();
await new Promise(r => setTimeout(r, 1000));

const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirmBtn) {
confirmBtn.click();
return { ok: true, message: 'Tweet successfully deleted.' };
} else {
return { ok: false, message: 'Delete confirmation dialog did not appear.' };
}
} catch (e) {
return { ok: false, message: e.toString() };
}
})()`;
}

cli({
site: 'twitter',
name: 'delete',
Expand All @@ -15,55 +85,18 @@ cli({
columns: ['status', 'message'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
let tweetId = '';
try {
tweetId = extractTweetId(kwargs.url);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new CommandExecutionError(message);
}

await page.goto(kwargs.url);
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely

const result = await page.evaluate(`(async () => {
try {
// Wait for caret button (which has 'More' aria-label) within the main tweet body
// Getting the first 'More' usually corresponds to the main displayed tweet of the URL
const moreMenu = document.querySelector('[aria-label="More"]');
if (!moreMenu) {
return { ok: false, message: 'Could not find the "More" context menu on this tweet. Are you sure you are logged in and looking at a valid tweet?' };
}

// Click the 'More' 3 dots button to open the dropdown menu
moreMenu.click();
await new Promise(r => setTimeout(r, 1000));

// Wait for dropdown pop-out to appear and look for the 'Delete' option
const items = document.querySelectorAll('[role="menuitem"]');
let deleteBtn = null;
for (const item of items) {
if (item.textContent.includes('Delete') && !item.textContent.includes('List')) {
deleteBtn = item;
break;
}
}

if (!deleteBtn) {
// If there's no Delete button, it's not our tweet OR localization is not English.
// Assuming English default for now.
return { ok: false, message: 'This tweet does not seem to belong to you, or the Delete option is missing (not your tweet).' };
}

// Click Delete
deleteBtn.click();
await new Promise(r => setTimeout(r, 1000));

// Find and click the confirmation 'Delete' prompt inside the modal
const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirmBtn) {
confirmBtn.click();
return { ok: true, message: 'Tweet successfully deleted.' };
} else {
return { ok: false, message: 'Delete confirmation dialog did not appear.' };
}
} catch (e) {
return { ok: false, message: e.toString() };
}
})()`);
const result = await page.evaluate(buildDeleteScript(tweetId));

if (result.ok) {
// Wait for the deletion request to be processed
Expand All @@ -76,3 +109,8 @@ cli({
}];
}
});

export const __test__ = {
buildDeleteScript,
extractTweetId,
};