import { describe, expect, it } from 'vitest'; import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; import { getRegistry } from '@jackwener/opencli/registry'; import { extractListEntry, isOwnedSubscribedEntry, parseListsManagement } from './lists.js'; describe('twitter lists parser', () => { it('extracts a list entry with full metadata', () => { const entry = { content: { itemContent: { list: { id_str: '1597593475389984769', name: 'Crypto', member_count: 44, subscriber_count: 8747, mode: 'Public', }, }, }, }; expect(extractListEntry(entry, new Set())).toEqual({ id: '1597593475389984769', name: 'Crypto', members: '44', followers: '8747', mode: 'public', }); }); it('maps Private mode to private', () => { const entry = { content: { itemContent: { list: { id_str: '2044679538156912976', name: 'AI & Agents', member_count: 15, subscriber_count: 0, mode: 'Private', }, }, }, }; expect(extractListEntry(entry, new Set())?.mode).toBe('private'); }); it('deduplicates by list id', () => { const entry = { content: { itemContent: { list: { id_str: '1', name: 'X' } } }, }; const seen = new Set(); expect(extractListEntry(entry, seen)).not.toBeNull(); expect(extractListEntry(entry, seen)).toBeNull(); }); it('returns null when no list payload is present', () => { expect(extractListEntry({}, new Set())).toBeNull(); expect(extractListEntry({ content: { itemContent: {} } }, new Set())).toBeNull(); }); it('parses ListsManagementPageTimeline payload instructions (real shape: nested module items)', () => { const payload = { data: { viewer: { list_management_timeline: { timeline: { instructions: [ { type: 'TimelineAddEntries', entries: [ { entryId: 'owned-subscribed-list-module-0', content: { entryType: 'TimelineTimelineModule', items: [ { entryId: 'owned-subscribed-list-module-0-list-1', item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '1', name: 'AI & Agents', member_count: 33, subscriber_count: 0, mode: 'Private' }, }, }, }, { entryId: 'owned-subscribed-list-module-0-list-2', item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '2', name: 'Anthropic Team', member_count: 10, subscriber_count: 0, mode: 'Public' }, }, }, }, ], }, }, ], }, ], }, }, }, }, }; const result = parseListsManagement(payload, new Set()); expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ id: '1', name: 'AI & Agents', mode: 'private' }); expect(result[1]).toMatchObject({ id: '2', name: 'Anthropic Team', mode: 'public' }); }); it('skips "Discover new Lists" recommendations (list-to-follow-module-*)', () => { // 真实 X.com //lists 响应:Discover 推荐 + Your Lists 同 instruction, // 区别只在 entry.entryId 前缀。Parser 必须按前缀剔除推荐。 const payload = { data: { viewer: { list_management_timeline: { timeline: { instructions: [ { type: 'TimelineAddEntries', entries: [ { entryId: 'list-to-follow-module-2050754937725386752', content: { entryType: 'TimelineTimelineModule', items: [ { entryId: 'list-to-follow-module-XYZ-list-1597593475389984769', item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '1597593475389984769', name: 'Crypto', member_count: 44, subscriber_count: 8947, mode: 'Public' } } }, }, { entryId: 'list-to-follow-module-XYZ-list-1499395616262217730', item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '1499395616262217730', name: 'Crypto Blockchain', member_count: 24, subscriber_count: 1166, mode: 'Public' } } }, }, ], }, }, { entryId: 'owned-subscribed-list-module-0', content: { entryType: 'TimelineTimelineModule', items: [ { entryId: 'owned-subscribed-list-module-0-list-2044679538156912976', item: { itemContent: { itemType: 'TimelineTwitterList', list: { id_str: '2044679538156912976', name: 'AI & Agents', member_count: 33, subscriber_count: 0, mode: 'Private' } } }, }, ], }, }, { entryId: 'cursor-bottom-2050754937725386750', content: { entryType: 'TimelineTimelineCursor' }, }, ], }, ], }, }, }, }, }; const result = parseListsManagement(payload, new Set()); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ id: '2044679538156912976', name: 'AI & Agents' }); // No Crypto/Blockchain leakage expect(result.find(l => l.name === 'Crypto')).toBeUndefined(); expect(result.find(l => l.name === 'Crypto Blockchain')).toBeUndefined(); }); it('isOwnedSubscribedEntry classifies entryIds', () => { expect(isOwnedSubscribedEntry({ entryId: 'owned-subscribed-list-module-0' })).toBe(true); expect(isOwnedSubscribedEntry({ entryId: 'list-to-follow-module-2050754937725386752' })).toBe(false); expect(isOwnedSubscribedEntry({ entryId: 'cursor-bottom-XYZ' })).toBe(false); expect(isOwnedSubscribedEntry({})).toBe(false); expect(isOwnedSubscribedEntry({ entryId: null })).toBe(false); }); it('returns empty list for malformed payload', () => { expect(parseListsManagement({}, new Set())).toEqual([]); expect(parseListsManagement({ data: {} }, new Set())).toEqual([]); }); it('dedupes across repeated entries within owned-subscribed module', () => { const itemA = { entryId: 'owned-subscribed-list-module-0-list-1', item: { itemContent: { list: { id_str: '1', name: 'A' } } }, }; const payload = { data: { viewer: { list_management_timeline: { timeline: { instructions: [{ entries: [{ entryId: 'owned-subscribed-list-module-0', content: { items: [itemA, itemA] }, }], }], }, }, }, }, }; const result = parseListsManagement(payload, new Set()); expect(result).toHaveLength(1); }); it('fails malformed command payloads as parser drift instead of empty success', async () => { const command = getRegistry().get('twitter/lists'); const page = { getCookies: async () => [{ name: 'ct0', value: 'token' }], evaluate: async (script) => { if (script.includes('placeholder.json')) return null; return { data: {} }; }, }; await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError); }); it('treats a recommendation-only timeline as a true empty result', async () => { const command = getRegistry().get('twitter/lists'); const page = { getCookies: async () => [{ name: 'ct0', value: 'token' }], evaluate: async (script) => { if (script.includes('placeholder.json')) return null; return { data: { viewer: { list_management_timeline: { timeline: { instructions: [{ entries: [{ entryId: 'list-to-follow-module-1', content: { items: [{ item: { itemContent: { list: { id_str: '9', name: 'Recommended' } } }, }], }, }], }], }, }, }, }, }; }, }; await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(EmptyResultError); }); });