Files
jackwener__opencli/clis/twitter/list-tweets.test.js
Ocean bb21e7e831 feat(twitter): GraphQL-based lists + list-tweets + list-add/remove (#1076)
* feat(twitter): rewrite lists via GraphQL + add list-tweets

The DOM-scraping / detail-click approach in PR #1053 remained fragile
against X's frequent overview-page rendering changes and slow (N+1 page
loads per list). Rewrite `twitter lists` to call
`ListsManagementPageTimeline` GraphQL directly — one request returns all
owned + subscribed lists with id/name/member_count/subscriber_count/mode.

Also add `twitter list-tweets <listId>` for pulling the tweet stream from
a list, completing the read-side chain (lists → pick an id → list-tweets).

- lists: drop positional `user` arg (GraphQL returns only logged-in
  user's lists), add `id` column, change followers to exact integer from
  subscriber_count.
- list-tweets: same GraphQL pattern as bookmarks/likes (BEARER + ct0 +
  dynamic queryId with static fallback + cursor pagination).
- Delete obsolete lists-parser.js and lists.d.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(twitter): add list-add / list-remove with Save-button commit

Two new commands to toggle list membership. X's list dialog uses a
"click-to-stage, Save-to-commit" pattern — clicking a row only updates
optimistic UI; the actual POST fires when the user clicks the top-right
"Save" button. Pressing ESC or the close-X silently cancels the change.

Implementation:
- Resolve listId → name via ListsManagementPageTimeline GraphQL, so we
  match the dialog row by name (dialog rows have no data-testid listId).
- Open profile page → DOM click "…" menu → "Add/remove from Lists".
- Scroll dialog to locate target row (virtualized list).
- page.nativeClick on row — trusted CDP Input.dispatchMouseEvent fires
  React's onclick, flips aria-checked (.click() alone does not suffice;
  X ignores non-trusted events for list mutations).
- page.nativeClick on the Save button — commits to server.
- Verify by re-fetching ListsManagementPageTimeline and diffing
  member_count: success only if N→N±1. No silent successes.

This fixes the pattern where batch `list-add` calls returned success for
every user but committed zero to the server (optimistic UI lied).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: stabilize twitter list manifest and query ids

* docs: add twitter list command discoverability

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-19 01:42:42 +08:00

109 lines
4.4 KiB
JavaScript

import { describe, expect, it } from 'vitest';
import { extractTimelineTweet, parseListTimeline } from './list-tweets.js';
describe('twitter list-tweets parser', () => {
it('extracts core tweet fields from a ListLatestTweetsTimeline result', () => {
const tweet = extractTimelineTweet({
rest_id: '99',
legacy: {
full_text: 'hello list',
favorite_count: 3,
retweet_count: 1,
reply_count: 2,
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
},
core: {
user_results: {
result: {
legacy: { screen_name: 'bob', name: 'Bob' },
},
},
},
}, new Set());
expect(tweet).toEqual({
id: '99',
author: 'bob',
name: 'Bob',
text: 'hello list',
likes: 3,
retweets: 1,
replies: 2,
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
url: 'https://x.com/bob/status/99',
});
});
it('prefers long-form note_tweet text over truncated legacy full_text', () => {
const tweet = extractTimelineTweet({
rest_id: '100',
legacy: { full_text: 'short…' },
note_tweet: {
note_tweet_results: {
result: { text: 'the full long-form body' },
},
},
core: { user_results: { result: { legacy: { screen_name: 'carol' } } } },
}, new Set());
expect(tweet?.text).toBe('the full long-form body');
});
it('deduplicates on rest_id', () => {
const seen = new Set();
const first = extractTimelineTweet({ rest_id: '1', legacy: {}, core: {} }, seen);
const second = extractTimelineTweet({ rest_id: '1', legacy: {}, core: {} }, seen);
expect(first).not.toBeNull();
expect(second).toBeNull();
});
it('parses entries and bottom cursor from the list timeline payload', () => {
const payload = {
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
entryId: 'tweet-1',
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: { full_text: 't1' },
core: { user_results: { result: { legacy: { screen_name: 'a' } } } },
},
},
},
},
},
{
entryId: 'cursor-bottom-1',
content: {
entryType: 'TimelineTimelineCursor',
cursorType: 'Bottom',
value: 'cursor-next',
},
},
],
},
],
},
},
},
},
};
const result = parseListTimeline(payload, new Set());
expect(result.nextCursor).toBe('cursor-next');
expect(result.tweets).toHaveLength(1);
expect(result.tweets[0]).toMatchObject({ id: '1', author: 'a', text: 't1' });
});
it('returns empty tweets and null cursor for malformed payload', () => {
const result = parseListTimeline({}, new Set());
expect(result.tweets).toEqual([]);
expect(result.nextCursor).toBeNull();
});
});