9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
360 lines
17 KiB
JavaScript
360 lines
17 KiB
JavaScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { __test__ } from './following.js';
|
|
|
|
describe('twitter following helpers', () => {
|
|
it('falls back when queryId contains unsafe characters', () => {
|
|
expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
|
|
expect(__test__.sanitizeQueryId('bad"id', 'fallback')).toBe('fallback');
|
|
expect(__test__.sanitizeQueryId('bad/id', 'fallback')).toBe('fallback');
|
|
expect(__test__.sanitizeQueryId(null, 'fallback')).toBe('fallback');
|
|
});
|
|
|
|
it('builds following url with cursor', () => {
|
|
const url = __test__.buildFollowingUrl('query123', '42', 20, 'cursor-1');
|
|
expect(url).toContain('/i/api/graphql/query123/Following');
|
|
expect(decodeURIComponent(url)).toContain('"userId":"42"');
|
|
expect(decodeURIComponent(url)).toContain('"count":20');
|
|
expect(decodeURIComponent(url)).toContain('"cursor":"cursor-1"');
|
|
});
|
|
|
|
it('builds following url without cursor', () => {
|
|
const url = __test__.buildFollowingUrl('query123', '42', 20);
|
|
expect(url).toContain('/i/api/graphql/query123/Following');
|
|
expect(decodeURIComponent(url)).not.toContain('"cursor"');
|
|
});
|
|
|
|
it('extracts user from result', () => {
|
|
const user = __test__.extractUser({
|
|
__typename: 'User',
|
|
core: { screen_name: 'alice', name: 'Alice' },
|
|
legacy: { description: 'bio text', followers_count: 100 },
|
|
});
|
|
expect(user).toMatchObject({
|
|
screen_name: 'alice',
|
|
name: 'Alice',
|
|
bio: 'bio text',
|
|
followers: 100,
|
|
});
|
|
});
|
|
|
|
it('returns null for non-User typename', () => {
|
|
expect(__test__.extractUser({ __typename: 'Tweet' })).toBeNull();
|
|
expect(__test__.extractUser(null)).toBeNull();
|
|
expect(__test__.extractUser(undefined)).toBeNull();
|
|
});
|
|
|
|
it('falls back to legacy screen_name if core is missing', () => {
|
|
const user = __test__.extractUser({
|
|
__typename: 'User',
|
|
legacy: { screen_name: 'bob', name: 'Bob', description: '', followers_count: 0 },
|
|
});
|
|
expect(user?.screen_name).toBe('bob');
|
|
});
|
|
|
|
it('typed-fails when upstream omits screen_name identity', () => {
|
|
expect(() => __test__.extractUser({
|
|
__typename: 'User',
|
|
legacy: { description: 'no names', followers_count: 7 },
|
|
})).toThrow(CommandExecutionError);
|
|
});
|
|
|
|
it('surfaces empty name display when upstream omits only name', () => {
|
|
const user = __test__.extractUser({
|
|
__typename: 'User',
|
|
core: { screen_name: 'alice' },
|
|
legacy: { description: 'no display name', followers_count: 7 },
|
|
});
|
|
expect(user).toMatchObject({
|
|
screen_name: 'alice',
|
|
name: '',
|
|
bio: 'no display name',
|
|
followers: 7,
|
|
});
|
|
});
|
|
|
|
it('parses following timeline with users and cursor', () => {
|
|
const payload = {
|
|
data: {
|
|
user: {
|
|
result: {
|
|
timeline_v2: {
|
|
timeline: {
|
|
instructions: [{
|
|
entries: [
|
|
{
|
|
entryId: 'user-1',
|
|
content: {
|
|
itemContent: {
|
|
user_results: {
|
|
result: {
|
|
__typename: 'User',
|
|
core: { screen_name: 'bob', name: 'Bob' },
|
|
legacy: { description: 'hello', followers_count: 50 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
entryId: 'user-2',
|
|
content: {
|
|
itemContent: {
|
|
user_results: {
|
|
result: {
|
|
__typename: 'User',
|
|
core: { screen_name: 'carol', name: 'Carol' },
|
|
legacy: { description: 'world', followers_count: 200 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
entryId: 'cursor-bottom-1',
|
|
content: {
|
|
entryType: 'TimelineTimelineCursor',
|
|
cursorType: 'Bottom',
|
|
value: 'next-cursor',
|
|
},
|
|
},
|
|
],
|
|
}],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = __test__.parseFollowing(payload);
|
|
expect(result.users).toHaveLength(2);
|
|
expect(result.users[0]).toMatchObject({ screen_name: 'bob', name: 'Bob', followers: 50 });
|
|
expect(result.users[1]).toMatchObject({ screen_name: 'carol', name: 'Carol', followers: 200 });
|
|
expect(result.nextCursor).toBe('next-cursor');
|
|
});
|
|
|
|
it('handles cursor-bottom entryId pattern', () => {
|
|
const payload = {
|
|
data: {
|
|
user: {
|
|
result: {
|
|
timeline: {
|
|
timeline: {
|
|
instructions: [{
|
|
entries: [
|
|
{
|
|
entryId: 'cursor-bottom-0',
|
|
content: {
|
|
itemContent: { value: 'cursor-val' },
|
|
},
|
|
},
|
|
],
|
|
}],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = __test__.parseFollowing(payload);
|
|
expect(result.nextCursor).toBe('cursor-val');
|
|
expect(result.users).toHaveLength(0);
|
|
});
|
|
|
|
it('returns empty users and null cursor for missing instructions', () => {
|
|
const result = __test__.parseFollowing({ data: { user: { result: {} } } });
|
|
expect(result.users).toHaveLength(0);
|
|
expect(result.nextCursor).toBeNull();
|
|
});
|
|
|
|
it('returns empty for completely empty payload', () => {
|
|
const result = __test__.parseFollowing({});
|
|
expect(result.users).toHaveLength(0);
|
|
expect(result.nextCursor).toBeNull();
|
|
});
|
|
|
|
it('normalizes screen names for CLI and profile-link inputs', () => {
|
|
expect(__test__.normalizeScreenName('@elonmusk')).toBe('elonmusk');
|
|
expect(__test__.normalizeScreenName('/elonmusk')).toBe('elonmusk');
|
|
expect(__test__.normalizeScreenName(' @@alice ')).toBe('alice');
|
|
expect(__test__.normalizeScreenName('/home')).toBe('');
|
|
expect(__test__.normalizeScreenName('/elonmusk/extra')).toBe('');
|
|
});
|
|
});
|
|
|
|
function followingPayload(users, cursor) {
|
|
return {
|
|
data: {
|
|
user: {
|
|
result: {
|
|
timeline_v2: {
|
|
timeline: {
|
|
instructions: [{
|
|
entries: [
|
|
...users.map((name) => ({
|
|
entryId: `user-${name}`,
|
|
content: {
|
|
itemContent: {
|
|
user_results: {
|
|
result: {
|
|
__typename: 'User',
|
|
core: { screen_name: name, name: name.toUpperCase() },
|
|
legacy: { description: `${name} bio`, followers_count: 10 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})),
|
|
...(cursor ? [{
|
|
entryId: `cursor-bottom-${cursor}`,
|
|
content: {
|
|
entryType: 'TimelineTimelineCursor',
|
|
cursorType: 'Bottom',
|
|
value: cursor,
|
|
},
|
|
}] : []),
|
|
],
|
|
}],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function bridgeEnvelope(data) {
|
|
return { session: 'site:twitter', data };
|
|
}
|
|
|
|
function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = { userId: '42' }, envelope = false } = {}) {
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
|
|
evaluate: vi.fn(async (script, ...args) => {
|
|
const wrap = (value) => envelope ? bridgeEnvelope(value) : value;
|
|
if (typeof script === 'function') {
|
|
const haystack = [script.toString(), ...args.map((arg) => String(arg))].join('\n');
|
|
if (haystack.includes('/UserByScreenName')) return wrap(userLookup);
|
|
if (haystack.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
|
|
if (haystack.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
|
|
throw new Error(`Unexpected evaluate function: ${haystack.slice(0, 80)}`);
|
|
}
|
|
if (script.includes('operationName')) return null;
|
|
if (script.includes('/UserByScreenName')) return wrap(userLookup);
|
|
if (script.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
|
|
if (script.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
|
|
throw new Error(`Unexpected evaluate script: ${script.slice(0, 80)}`);
|
|
}),
|
|
};
|
|
return page;
|
|
}
|
|
|
|
describe('twitter following command', () => {
|
|
it('paginates with cursor, deduplicates users, strips @, and respects limit', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([
|
|
followingPayload(['alice', 'bob'], 'cursor-1'),
|
|
followingPayload(['bob', 'carol', 'dave'], null),
|
|
]);
|
|
|
|
const rows = await command.func(page, { user: '@elonmusk', limit: 3 });
|
|
|
|
expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
|
|
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
|
|
const userLookupScript = callText(page.evaluate.mock.calls.find((call) => callText(call).includes('/UserByScreenName')) || []);
|
|
expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
|
|
expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
|
|
const followingCalls = page.evaluate.mock.calls.filter((call) => callText(call).includes('/Following'));
|
|
expect(followingCalls).toHaveLength(2);
|
|
expect(decodeURIComponent(callText(followingCalls[1]))).toContain('"cursor":"cursor-1"');
|
|
});
|
|
|
|
it('rejects invalid limits before navigating', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([]);
|
|
|
|
await expect(command.func(page, { user: 'elonmusk', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects invalid explicit users before cookies or navigation', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([]);
|
|
|
|
await expect(command.func(page, { user: 'elonmusk/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
|
|
expect(page.getCookies).not.toHaveBeenCalled();
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects route-like AppTabBar hrefs as AuthRequiredError', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([]);
|
|
page.evaluate.mockImplementation(async (script, ...args) => {
|
|
const haystack = [typeof script === 'function' ? script.toString() : String(script), ...args.map((arg) => String(arg))].join('\n');
|
|
if (haystack.includes('AppTabBar_Profile_Link')) return '/home';
|
|
throw new Error(`Unexpected evaluate: ${haystack.slice(0, 80)}`);
|
|
});
|
|
|
|
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
|
|
});
|
|
|
|
it('maps first-page auth failures to AuthRequiredError', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([{ error: 401 }]);
|
|
|
|
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
});
|
|
|
|
it('does not silently return partial rows when a later page fails', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([
|
|
followingPayload(['alice'], 'cursor-1'),
|
|
{ error: 429 },
|
|
]);
|
|
|
|
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
it('maps user lookup auth failures to AuthRequiredError', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([], { userLookup: { error: 403 } });
|
|
|
|
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
});
|
|
|
|
it('unwraps Browser Bridge envelopes for user lookup and following payloads', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([followingPayload(['alice'], null)], { envelope: true });
|
|
|
|
const rows = await command.func(page, { user: 'elonmusk', limit: 10 });
|
|
|
|
expect(rows.map((row) => row.screen_name)).toEqual(['alice']);
|
|
const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
|
|
const followingCall = page.evaluate.mock.calls.find((call) => callText(call).includes('/Following')) || [];
|
|
const followingUrl = String(followingCall[1] || '');
|
|
expect(decodeURIComponent(followingUrl)).toContain('"userId":"42"');
|
|
expect(decodeURIComponent(followingUrl)).not.toContain('[object Object]');
|
|
});
|
|
|
|
it('fails fast when the following timeline is empty', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([followingPayload([], null)]);
|
|
|
|
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
});
|
|
|
|
it('surfaces the private-following privacy hint when result.timeline is empty', async () => {
|
|
const command = getRegistry().get('twitter/following');
|
|
const page = createFollowingPage([{ data: { user: { result: { __typename: 'User', timeline: {} } } } }]);
|
|
|
|
await expect(command.func(page, { user: 'simonw', limit: 10 }))
|
|
.rejects.toMatchObject({ hint: expect.stringContaining('following list to private') });
|
|
});
|
|
});
|