Files
jackwener__opencli/clis/twitter/shared.test.js
jakevin 8ef7e903b8 feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug (#1531)
* feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug

Primary: make `opencli twitter tweets` default to the logged-in user
when no username is given, so agents can pull their own posts without
needing to know their own handle. Mirrors the existing self-detection
pattern in twitter/profile and twitter/likes (AppTabBar_Profile_Link
probe on /home, then UserByScreenName lookup). Description + help
string now mention the default so agents discover it.

Consistency pass — profile/likes/following/followers: the
self-detection in these four siblings was silently broken because
page.evaluate() primitive returns come back through the CDP bridge
wrapped as `{session: 'site:twitter', data: '/<handle>'}` (same
envelope root cause as #1525). They called `.replace()` directly on
the envelope object → TypeError surfaced as AUTH_REQUIRED 'Could not
detect logged-in user', even for logged-in users. Wrap each probe
with unwrapBrowserResult so the bare href string survives. Also:
- Add an explicit page.goto('/home') + page.wait(primaryColumn)
  before the probe in likes/following so the AppTabBar sidebar is
  guaranteed rendered (framework pre-nav lands on bare x.com without
  the sidebar mounted).
- following.js: switch its probe from the function-literal form
  `() => {...}` to a template-string. Confirmed live: function-literal
  silently drops primitive returns entirely — bridge returns
  `{session}` with no `data` field at all, while template-string
  returns `{session, data}` as expected.

Out of scope (pre-existing, flagged as follow-up): likes/following
have additional downstream evaluate paths (userId/GraphQL fetch) that
still drop or envelope their results; they return [] or
'Could not find user' even after this PR. Same daemon-side bug class
as #1525.

Live-verified:
  opencli twitter tweets --limit 2     → own tweets (@jakevin7)
  opencli twitter profile              → own profile

Tests 227/227, audits typed-error-lint 189 + silent-column-drop 103
unchanged, manifest stable at 816 entries.

* fix(twitter): validate self-detected handles

* fix(twitter): unwrap downstream self evaluate results
2026-05-13 22:51:48 +08:00

331 lines
15 KiB
JavaScript

import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { __test__ } from './shared.js';
import { ArgumentError } from '@jackwener/opencli/errors';
const { extractMedia, parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult, normalizeTwitterGraphqlPayload, normalizeTwitterScreenName, sanitizeTwitterOperationMetadata } = __test__;
describe('twitter browser result helpers', () => {
it('unwraps Browser Bridge exec envelopes', () => {
expect(unwrapBrowserResult({ session: 'site:twitter', data: '123' })).toBe('123');
expect(unwrapBrowserResult({ data: { user: true } })).toEqual({ data: { user: true } });
});
it('sanitizes operation metadata after unwrapping Browser Bridge envelopes', () => {
const result = sanitizeTwitterOperationMetadata({
session: 'site:twitter',
data: {
queryId: 'abc_123',
features: { feature: true },
fieldToggles: { field: true },
},
}, { queryId: 'fallback', features: {}, fieldToggles: {} });
expect(result).toEqual({
queryId: 'abc_123',
features: { feature: true },
fieldToggles: { field: true },
});
});
it('falls back to baked features / fieldToggles when the bundle parser returns empty maps', () => {
// Regression guard: resolveTwitterOperationMetadata's bundle parser can
// find a queryId but miss `featureSwitches:[...]` (e.g. minification
// change, or the 2500-char snippet window truncating before the array).
// In that case keysToFlags(undefined) returns {}; if sanitize kept the
// empty map, Twitter would receive a request with no features and reply
// 400, surfacing a misleading "queryId expired" error.
const result = sanitizeTwitterOperationMetadata({
queryId: 'newQueryId',
features: {},
fieldToggles: {},
}, {
queryId: 'fallback',
features: { fallback_feature: true },
fieldToggles: { fallback_field: true },
});
expect(result).toEqual({
queryId: 'newQueryId',
features: { fallback_feature: true },
fieldToggles: { fallback_field: true },
});
});
it('falls back when resolved features are non-object falsy values', () => {
const result = sanitizeTwitterOperationMetadata({
queryId: 'newQueryId',
features: null,
fieldToggles: undefined,
}, {
queryId: 'fallback',
features: { fallback_feature: true },
fieldToggles: { fallback_field: true },
});
expect(result.features).toEqual({ fallback_feature: true });
expect(result.fieldToggles).toEqual({ fallback_field: true });
});
it('normalizes GraphQL payloads when the bridge strips the top-level data key', () => {
expect(normalizeTwitterGraphqlPayload({ user: { result: {} } })).toEqual({
data: { user: { result: {} } },
});
expect(normalizeTwitterGraphqlPayload({ search_by_raw_query: { search_timeline: {} } })).toEqual({
data: { search_by_raw_query: { search_timeline: {} } },
});
expect(normalizeTwitterGraphqlPayload({ data: { user: {} } })).toEqual({ data: { user: {} } });
});
});
describe('twitter normalizeTwitterScreenName', () => {
it('accepts exact handles and exact Twitter/X profile URLs', () => {
expect(normalizeTwitterScreenName('@viewer')).toBe('viewer');
expect(normalizeTwitterScreenName('/viewer')).toBe('viewer');
expect(normalizeTwitterScreenName('https://x.com/viewer')).toBe('viewer');
expect(normalizeTwitterScreenName('https://twitter.com/viewer?lang=en')).toBe('viewer');
expect(normalizeTwitterScreenName('https://mobile.twitter.com/viewer')).toBe('viewer');
});
it('rejects route collisions, malformed handles, and non-exact profile URLs', () => {
const invalid = [
'/home',
'/viewer/extra',
'viewer/extra',
'viewer?tab=posts',
'https://x.com/home',
'https://x.com/viewer/status/1',
'http://x.com/viewer',
'https://evil.com/viewer',
'https://x.com.evil.com/viewer',
'https://x.com:444/viewer',
'https://user:pass@x.com/viewer',
'bad-handle',
'abcdefghijklmnop',
];
for (const value of invalid) {
expect(normalizeTwitterScreenName(value)).toBe('');
}
});
});
describe('twitter parseTweetUrl', () => {
it('accepts exact Twitter/X tweet URLs and preserves query parameters', () => {
expect(parseTweetUrl('https://x.com/alice/status/2040254679301718161?s=20')).toEqual({
id: '2040254679301718161',
url: 'https://x.com/alice/status/2040254679301718161?s=20',
});
expect(parseTweetUrl('https://mobile.twitter.com/i/status/2040318731105313143')).toEqual({
id: '2040318731105313143',
url: 'https://mobile.twitter.com/i/status/2040318731105313143',
});
});
it('rejects non-https, off-domain, host-suffix, embedded, and path-suffix URLs', () => {
const invalid = [
'http://x.com/alice/status/2040254679301718161',
'https://evil.com/alice/status/2040254679301718161',
'https://x.com.evil.com/alice/status/2040254679301718161',
'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
'https://x.com/alice/status/2040254679301718161/photo/1',
];
for (const url of invalid) {
expect(() => parseTweetUrl(url)).toThrow(ArgumentError);
}
});
});
describe('twitter buildTwitterArticleScopeSource', () => {
// JSDOM-based tests prove the returned source actually works on real DOM —
// mocked `evaluate` tests in adapter specs only verify the script string
// contains expected tokens, but cannot catch silent matching bugs (cf.
// dianping #1312: mocked-evaluate single tests miss in-browser logic bugs).
function loadHelpers(tweetId, dom) {
const source = buildTwitterArticleScopeSource(tweetId);
const probe = new Function(
'document',
'window',
'URL',
`${source}\nreturn { findTargetArticle, __twHasLinkToTarget, __twGetStatusIdFromHref };`,
);
return probe(dom.window.document, dom.window, dom.window.URL);
}
function makeDom(html) {
return new JSDOM(`<html><body>${html}</body></html>`, { url: 'https://x.com/alice/status/2040254679301718161' });
}
it('finds the article whose link exactly matches the requested status id', () => {
const dom = makeDom(`
<article id="a"><a href="https://x.com/alice/status/2040254679301718161">link</a></article>
<article id="b"><a href="https://x.com/bob/status/9999999999999999999">link</a></article>
`);
const helpers = loadHelpers('2040254679301718161', dom);
const article = helpers.findTargetArticle();
expect(article?.id).toBe('a');
});
it('rejects substring matches — tweet id 123 must not match /status/1234567', () => {
// This is the codex-mini0 #1400 catch (substring vulnerability):
// `/status/123` was accepted as a substring of `/status/1234567`.
const dom = makeDom('<article><a href="https://x.com/alice/status/1234567">link</a></article>');
const helpers = loadHelpers('123', dom);
expect(helpers.findTargetArticle()).toBeUndefined();
});
it('rejects path-suffix attack — /status/<id>/photo/1 must not match status <id>', () => {
// Same regex anchor that parseTweetUrl uses — guards against attached
// paths like `/photo/1` that would otherwise pass with a loose suffix.
const dom = makeDom('<article><a href="https://x.com/alice/status/2040254679301718161/photo/1">link</a></article>');
const helpers = loadHelpers('2040254679301718161', dom);
expect(helpers.findTargetArticle()).toBeUndefined();
});
it('rejects off-domain links even when the path has the requested status id', () => {
const dom = makeDom('<article><a href="https://evil.com/alice/status/2040254679301718161">link</a></article>');
const helpers = loadHelpers('2040254679301718161', dom);
expect(helpers.findTargetArticle()).toBeUndefined();
});
it('rejects host-suffix and non-https status links', () => {
const dom = makeDom(`
<article id="suffix"><a href="https://x.com.evil.com/alice/status/2040254679301718161">link</a></article>
<article id="http"><a href="http://x.com/alice/status/2040254679301718161">link</a></article>
`);
const helpers = loadHelpers('2040254679301718161', dom);
expect(helpers.findTargetArticle()).toBeUndefined();
});
it('accepts exact Twitter/X status links with query and hash suffixes', () => {
const dom = makeDom('<article id="ok"><a href="https://mobile.twitter.com/alice/status/2040254679301718161?s=20#fragment">link</a></article>');
const helpers = loadHelpers('2040254679301718161', dom);
expect(helpers.findTargetArticle()?.id).toBe('ok');
});
it('matches /i/status/<id> URL form', () => {
const dom = makeDom('<article><a href="https://x.com/i/status/2040318731105313143">link</a></article>');
const helpers = loadHelpers('2040318731105313143', dom);
expect(helpers.findTargetArticle()).toBeTruthy();
});
it('__twHasLinkToTarget reports true on any descendant <a> matching tweet id', () => {
// Used by quote-card guard in quote.js — the quoted tweet card is not
// inside an <article>, but somewhere on the compose page.
const dom = makeDom(`
<div data-testid="card.wrapper">
<a href="https://x.com/alice/status/2040254679301718161">quoted card</a>
</div>
`);
const helpers = loadHelpers('2040254679301718161', dom);
expect(helpers.__twHasLinkToTarget(dom.window.document)).toBe(true);
});
it('__twGetStatusIdFromHref returns null on non-status URLs', () => {
const dom = makeDom('');
const helpers = loadHelpers('123', dom);
expect(helpers.__twGetStatusIdFromHref('https://x.com/alice/home')).toBeNull();
expect(helpers.__twGetStatusIdFromHref('https://x.com/alice/status/123/photo/1')).toBeNull();
expect(helpers.__twGetStatusIdFromHref('https://evil.com/alice/status/123')).toBeNull();
expect(helpers.__twGetStatusIdFromHref('https://x.com.evil.com/alice/status/123')).toBeNull();
expect(helpers.__twGetStatusIdFromHref('http://x.com/alice/status/123')).toBeNull();
expect(helpers.__twGetStatusIdFromHref('not a url')).toBeNull();
});
it('emits the canonical regex anchor — guards future maintainers from dropping ^ or $', () => {
const source = buildTwitterArticleScopeSource('123');
// Source-level assertion complements the JSDOM behavioural tests above.
// If a future refactor relaxes the anchor (e.g. drops ^ or $), the
// JSDOM tests would still pass on benign inputs but fail on adversarial
// cases. This token check ensures the regex shape itself is preserved.
expect(source).toContain('/^\\/(?:[^/]+|i)\\/status\\/(\\d+)\\/?$/');
});
});
describe('twitter extractMedia', () => {
it('returns false + empty list when legacy has no media', () => {
expect(extractMedia({})).toEqual({ has_media: false, media_urls: [] });
expect(extractMedia(undefined)).toEqual({ has_media: false, media_urls: [] });
expect(extractMedia({ extended_entities: { media: [] } })).toEqual({
has_media: false,
media_urls: [],
});
});
it('extracts photo urls from extended_entities', () => {
const result = extractMedia({
extended_entities: {
media: [
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/a.jpg' },
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/b.jpg' },
],
},
});
expect(result.has_media).toBe(true);
expect(result.media_urls).toEqual([
'https://pbs.twimg.com/media/a.jpg',
'https://pbs.twimg.com/media/b.jpg',
]);
});
it('prefers mp4 variant for video and animated_gif', () => {
const result = extractMedia({
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/media/thumb.jpg',
video_info: {
variants: [
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/x.m3u8' },
{ content_type: 'video/mp4', url: 'https://video.twimg.com/x.mp4' },
],
},
},
{
type: 'animated_gif',
media_url_https: 'https://pbs.twimg.com/tweet_video_thumb/g.jpg',
video_info: {
variants: [
{ content_type: 'video/mp4', url: 'https://video.twimg.com/g.mp4' },
],
},
},
],
},
});
expect(result.has_media).toBe(true);
expect(result.media_urls).toEqual([
'https://video.twimg.com/x.mp4',
'https://video.twimg.com/g.mp4',
]);
});
it('falls back to media_url_https when no mp4 variant is available', () => {
const result = extractMedia({
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/media/thumb.jpg',
video_info: { variants: [] },
},
],
},
});
expect(result).toEqual({
has_media: true,
media_urls: ['https://pbs.twimg.com/media/thumb.jpg'],
});
});
it('falls back to entities.media when extended_entities is missing', () => {
const result = extractMedia({
entities: {
media: [
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/c.jpg' },
],
},
});
expect(result).toEqual({
has_media: true,
media_urls: ['https://pbs.twimg.com/media/c.jpg'],
});
});
});