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
93 changes: 88 additions & 5 deletions src/clis/twitter/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,98 @@ describe('twitter search command', () => {
expect(pushStateCall).toContain('f=top');
});

it('falls back to search input when pushState fails twice', async () => {
const command = getRegistry().get('twitter/search');
expect(command?.func).toBeTypeOf('function');

const evaluate = vi.fn()
.mockResolvedValueOnce(undefined) // pushState attempt 1
.mockResolvedValueOnce('/explore') // pathname check 1 — not /search
.mockResolvedValueOnce(undefined) // pushState attempt 2
.mockResolvedValueOnce('/explore') // pathname check 2 — still not /search
.mockResolvedValueOnce({ ok: true }) // search input fallback succeeds
.mockResolvedValueOnce('/search'); // pathname check after fallback

const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
evaluate,
autoScroll: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([
{
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: 'tweet-99',
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '99',
legacy: {
full_text: 'fallback works',
favorite_count: 3,
created_at: 'Wed Apr 02 12:00:00 +0000 2026',
},
core: {
user_results: {
result: {
core: { screen_name: 'bob' },
},
},
},
views: { count: '5' },
},
},
},
},
},
],
},
],
},
},
},
},
},
]),
};

const result = await command!.func!(page as any, { query: 'test fallback', filter: 'top', limit: 5 });

expect(result).toEqual([
{
id: '99',
author: 'bob',
text: 'fallback works',
created_at: 'Wed Apr 02 12:00:00 +0000 2026',
likes: 3,
views: '5',
url: 'https://x.com/i/status/99',
},
]);
// 6 evaluate calls: 2x pushState + 2x pathname check + 1x fallback + 1x pathname check
expect(evaluate).toHaveBeenCalledTimes(6);
expect(page.autoScroll).toHaveBeenCalled();
});

it('throws with the final path after both attempts fail', async () => {
const command = getRegistry().get('twitter/search');
expect(command?.func).toBeTypeOf('function');

const evaluate = vi.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce('/explore')
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce('/login');
.mockResolvedValueOnce(undefined) // pushState attempt 1
.mockResolvedValueOnce('/explore') // pathname check 1
.mockResolvedValueOnce(undefined) // pushState attempt 2
.mockResolvedValueOnce('/login') // pathname check 2
.mockResolvedValueOnce({ ok: false }); // search input fallback

const page = {
goto: vi.fn().mockResolvedValue(undefined),
Expand All @@ -177,6 +260,6 @@ describe('twitter search command', () => {
.toThrow('Final path: /login');
expect(page.autoScroll).not.toHaveBeenCalled();
expect(page.getInterceptedRequests).not.toHaveBeenCalled();
expect(evaluate).toHaveBeenCalledTimes(4);
expect(evaluate).toHaveBeenCalledTimes(5);
});
});
73 changes: 68 additions & 5 deletions src/clis/twitter/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,32 @@ import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';

/**
* Trigger Twitter search SPA navigation and retry once on transient failures.
* Trigger Twitter search SPA navigation with fallback strategies.
*
* Twitter/X sometimes keeps the page on /explore for a short period even after
* pushState + popstate. A second attempt is enough for the intermittent cases
* reported in issue #353 while keeping the flow narrowly scoped.
* Primary: pushState + popstate (works in most environments).
* Fallback: Type into the search input and press Enter when pushState fails
* intermittently (e.g. due to Twitter A/B tests or timing races — see #690).
*
* Both strategies preserve the JS context so the fetch interceptor stays alive.
*/
async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: string, filter: string): Promise<void> {
const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${filter}`);
let lastPath = '';

// Strategy 1 (primary): pushState + popstate with retry
for (let attempt = 1; attempt <= 2; attempt++) {
await page.evaluate(`
(() => {
window.history.pushState({}, '', ${searchUrl});
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
})()
`);
await page.wait({ selector: '[data-testid="primaryColumn"]' });

try {
await page.wait({ selector: '[data-testid="primaryColumn"]' });
} catch {
// selector timeout — fall through to path check or next attempt
}

lastPath = String(await page.evaluate('() => window.location.pathname') || '');
if (lastPath.startsWith('/search')) {
Expand All @@ -32,6 +40,61 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
}
}

// Strategy 2 (fallback): Use the search input on /explore.
// The nativeSetter + Enter approach triggers Twitter's own form handler,
// performing SPA navigation without a full page reload.
const queryStr = JSON.stringify(query);
const navResult = await page.evaluate(`(async () => {
try {
const input = document.querySelector('[data-testid="SearchBox_Search_Input"]');
if (!input) return { ok: false };

input.focus();
await new Promise(r => setTimeout(r, 300));

const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
if (!nativeSetter) return { ok: false };
nativeSetter.call(input, ${queryStr});
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
await new Promise(r => setTimeout(r, 500));

input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
}));

return { ok: true };
} catch {
return { ok: false };
}
})()`);

if (navResult?.ok) {
try {
await page.wait({ selector: '[data-testid="primaryColumn"]' });
} catch {
// fall through to path check
}
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
if (lastPath.startsWith('/search')) {
if (filter === 'live') {
await page.evaluate(`(() => {
const tabs = document.querySelectorAll('[role="tab"]');
for (const tab of tabs) {
if (tab.textContent.includes('Latest') || tab.textContent.includes('最新')) {
tab.click();
return;
}
}
})()`);
await page.wait(2);
}
return;
}
}

throw new CommandExecutionError(
`SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`,
);
Expand Down