chore: tighten lint rules and tests
This commit is contained in:
+20
-2
@@ -11,10 +11,28 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off"
|
"noForEach": "error"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"useImportExtensions": {
|
||||||
|
"level": "error",
|
||||||
|
"options": {
|
||||||
|
"forceJsExtensions": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"useTopLevelRegex": "error"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"noNonNullAssertion": "off"
|
"noNonNullAssertion": "error",
|
||||||
|
"useBlockStatements": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "error"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"useRegexpExec": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+63
-28
@@ -32,16 +32,19 @@ import { runtimeQueryIds } from './lib/runtime-query-ids.js';
|
|||||||
import { type TweetData, TwitterClient } from './lib/twitter-client.js';
|
import { type TweetData, TwitterClient } from './lib/twitter-client.js';
|
||||||
import { getCliVersion } from './lib/version.js';
|
import { getCliVersion } from './lib/version.js';
|
||||||
|
|
||||||
const program = new Command();
|
const program: Command = new Command();
|
||||||
|
|
||||||
const rawArgs = process.argv.slice(2);
|
const rawArgs: string[] = process.argv.slice(2);
|
||||||
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
|
const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
|
||||||
const isTty = process.stdout.isTTY;
|
const isTty: boolean = process.stdout.isTTY;
|
||||||
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
|
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
|
||||||
kleur.enabled = output.color;
|
kleur.enabled = output.color;
|
||||||
|
|
||||||
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
|
const wrap =
|
||||||
const collect = (value: string, previous: string[] = []) => {
|
(styler: (text: string) => string): ((text: string) => string) =>
|
||||||
|
(text: string): string =>
|
||||||
|
isTty ? styler(text) : text;
|
||||||
|
const collect = (value: string, previous: string[] = []): string[] => {
|
||||||
previous.push(value);
|
previous.push(value);
|
||||||
return previous;
|
return previous;
|
||||||
};
|
};
|
||||||
@@ -50,16 +53,22 @@ const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
|
|||||||
|
|
||||||
function parseCookieSource(value: string): CookieSource {
|
function parseCookieSource(value: string): CookieSource {
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') return normalized;
|
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
|
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
|
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
|
||||||
if (typeof input === 'string') return [parseCookieSource(input)];
|
if (typeof input === 'string') {
|
||||||
|
return [parseCookieSource(input)];
|
||||||
|
}
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
const result: CookieSource[] = [];
|
const result: CookieSource[] = [];
|
||||||
for (const entry of input) {
|
for (const entry of input) {
|
||||||
if (typeof entry !== 'string') continue;
|
if (typeof entry !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
result.push(parseCookieSource(entry));
|
result.push(parseCookieSource(entry));
|
||||||
}
|
}
|
||||||
return result.length > 0 ? result : undefined;
|
return result.length > 0 ? result : undefined;
|
||||||
@@ -67,15 +76,15 @@ function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectCookieSource = (value: string, previous: CookieSource[] = []) => {
|
const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
|
||||||
previous.push(parseCookieSource(value));
|
previous.push(parseCookieSource(value));
|
||||||
return previous;
|
return previous;
|
||||||
};
|
};
|
||||||
|
|
||||||
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
|
const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
|
||||||
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
|
const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
|
||||||
|
|
||||||
function applyOutputFromCommand(command: Command) {
|
function applyOutputFromCommand(command: Command): void {
|
||||||
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
|
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
|
||||||
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
|
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
|
||||||
kleur.enabled = output.color;
|
kleur.enabled = output.color;
|
||||||
@@ -102,7 +111,9 @@ type BirdConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function readConfigFile(path: string): Partial<BirdConfig> {
|
function readConfigFile(path: string): Partial<BirdConfig> {
|
||||||
if (!existsSync(path)) return {};
|
if (!existsSync(path)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(path, 'utf8');
|
const raw = readFileSync(path, 'utf8');
|
||||||
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
|
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
|
||||||
@@ -151,7 +162,7 @@ program.addHelpText(
|
|||||||
|
|
||||||
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
|
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
|
||||||
|
|
||||||
const formatExample = (command: string, description: string) =>
|
const formatExample = (command: string, description: string): string =>
|
||||||
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
|
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
|
||||||
|
|
||||||
program.addHelpText(
|
program.addHelpText(
|
||||||
@@ -192,7 +203,7 @@ type CredentialsOptions = {
|
|||||||
cookieSource?: CookieSource[];
|
cookieSource?: CookieSource[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveCredentialsFromOptions(opts: CredentialsOptions) {
|
function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
|
||||||
const cookieSource = opts.cookieSource?.length
|
const cookieSource = opts.cookieSource?.length
|
||||||
? opts.cookieSource
|
? opts.cookieSource
|
||||||
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
|
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
|
||||||
@@ -213,9 +224,13 @@ type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
|
|||||||
|
|
||||||
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
|
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value === undefined || value === null || value === '') continue;
|
if (value === undefined || value === null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const parsed = typeof value === 'number' ? value : Number(value);
|
const parsed = typeof value === 'number' ? value : Number(value);
|
||||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -226,17 +241,31 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb
|
|||||||
|
|
||||||
function detectMime(path: string): string | null {
|
function detectMime(path: string): string | null {
|
||||||
const ext = path.toLowerCase();
|
const ext = path.toLowerCase();
|
||||||
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
|
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
|
||||||
if (ext.endsWith('.png')) return 'image/png';
|
return 'image/jpeg';
|
||||||
if (ext.endsWith('.webp')) return 'image/webp';
|
}
|
||||||
if (ext.endsWith('.gif')) return 'image/gif';
|
if (ext.endsWith('.png')) {
|
||||||
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4';
|
return 'image/png';
|
||||||
if (ext.endsWith('.mov')) return 'video/quicktime';
|
}
|
||||||
|
if (ext.endsWith('.webp')) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
if (ext.endsWith('.gif')) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
|
||||||
|
return 'video/mp4';
|
||||||
|
}
|
||||||
|
if (ext.endsWith('.mov')) {
|
||||||
|
return 'video/quicktime';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
|
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
|
||||||
if (opts.media.length === 0) return [];
|
if (opts.media.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const specs: MediaSpec[] = [];
|
const specs: MediaSpec[] = [];
|
||||||
for (const [index, path] of opts.media.entries()) {
|
for (const [index, path] of opts.media.entries()) {
|
||||||
const mime = detectMime(path);
|
const mime = detectMime(path);
|
||||||
@@ -248,9 +277,15 @@ function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
|
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
|
||||||
if (videoCount > 1) throw new Error('Only one video can be attached');
|
if (videoCount > 1) {
|
||||||
if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media');
|
throw new Error('Only one video can be attached');
|
||||||
if (specs.length > 4) throw new Error('Maximum 4 media attachments');
|
}
|
||||||
|
if (videoCount === 1 && specs.length > 1) {
|
||||||
|
throw new Error('Video cannot be combined with other media');
|
||||||
|
}
|
||||||
|
if (specs.length > 4) {
|
||||||
|
throw new Error('Maximum 4 media attachments');
|
||||||
|
}
|
||||||
return specs;
|
return specs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
|
|||||||
|
|
||||||
export function looksLikeTweetInput(value: string): boolean {
|
export function looksLikeTweetInput(value: string): boolean {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
|
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-14
@@ -21,10 +21,12 @@ export type CookieSource = 'safari' | 'chrome' | 'firefox';
|
|||||||
|
|
||||||
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
|
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
|
||||||
const TWITTER_URL = 'https://x.com/';
|
const TWITTER_URL = 'https://x.com/';
|
||||||
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/'];
|
const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/'];
|
||||||
|
|
||||||
function normalizeValue(value: unknown): string | null {
|
function normalizeValue(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
@@ -38,24 +40,36 @@ function buildEmpty(): TwitterCookies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
|
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
|
||||||
if (cookies[field]) return;
|
if (cookies[field]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = normalizeValue(process.env[key]);
|
const value = normalizeValue(process.env[key]);
|
||||||
if (!value) continue;
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
cookies[field] = value;
|
cookies[field] = value;
|
||||||
if (!cookies.source) cookies.source = `env ${key}`;
|
if (!cookies.source) {
|
||||||
|
cookies.source = `env ${key}`;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
|
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
|
||||||
if (Array.isArray(cookieSource)) return cookieSource;
|
if (Array.isArray(cookieSource)) {
|
||||||
if (cookieSource) return [cookieSource];
|
return cookieSource;
|
||||||
|
}
|
||||||
|
if (cookieSource) {
|
||||||
|
return [cookieSource];
|
||||||
|
}
|
||||||
return ['safari', 'chrome', 'firefox'];
|
return ['safari', 'chrome', 'firefox'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelForSource(source: CookieSource, profile?: string): string {
|
function labelForSource(source: CookieSource, profile?: string): string {
|
||||||
if (source === 'safari') return 'Safari';
|
if (source === 'safari') {
|
||||||
|
return 'Safari';
|
||||||
|
}
|
||||||
if (source === 'chrome') {
|
if (source === 'chrome') {
|
||||||
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
||||||
}
|
}
|
||||||
@@ -67,13 +81,19 @@ function pickCookieValue(
|
|||||||
name: (typeof TWITTER_COOKIE_NAMES)[number],
|
name: (typeof TWITTER_COOKIE_NAMES)[number],
|
||||||
): string | null {
|
): string | null {
|
||||||
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
|
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
|
||||||
if (matches.length === 0) return null;
|
if (matches.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
|
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
|
||||||
if (preferred?.value) return preferred.value;
|
if (preferred?.value) {
|
||||||
|
return preferred.value;
|
||||||
|
}
|
||||||
|
|
||||||
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
|
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
|
||||||
if (twitter?.value) return twitter.value;
|
if (twitter?.value) {
|
||||||
|
return twitter.value;
|
||||||
|
}
|
||||||
|
|
||||||
return matches[0]?.value ?? null;
|
return matches[0]?.value ?? null;
|
||||||
}
|
}
|
||||||
@@ -99,8 +119,12 @@ async function readTwitterCookiesFromBrowser(options: {
|
|||||||
|
|
||||||
const authToken = pickCookieValue(cookies, 'auth_token');
|
const authToken = pickCookieValue(cookies, 'auth_token');
|
||||||
const ct0 = pickCookieValue(cookies, 'ct0');
|
const ct0 = pickCookieValue(cookies, 'ct0');
|
||||||
if (authToken) out.authToken = authToken;
|
if (authToken) {
|
||||||
if (ct0) out.ct0 = ct0;
|
out.authToken = authToken;
|
||||||
|
}
|
||||||
|
if (ct0) {
|
||||||
|
out.ct0 = ct0;
|
||||||
|
}
|
||||||
|
|
||||||
if (out.authToken && out.ct0) {
|
if (out.authToken && out.ct0) {
|
||||||
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
|
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
|
||||||
@@ -156,7 +180,9 @@ export async function resolveCredentials(options: {
|
|||||||
}
|
}
|
||||||
if (options.ct0) {
|
if (options.ct0) {
|
||||||
cookies.ct0 = options.ct0;
|
cookies.ct0 = options.ct0;
|
||||||
if (!cookies.source) cookies.source = 'CLI argument';
|
if (!cookies.source) {
|
||||||
|
cookies.source = 'CLI argument';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
|
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
|
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
|
||||||
*/
|
*/
|
||||||
|
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
|
||||||
|
|
||||||
export function extractTweetId(input: string): string {
|
export function extractTweetId(input: string): string {
|
||||||
// If it's a URL, extract the tweet ID
|
// If it's a URL, extract the tweet ID
|
||||||
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
|
const urlMatch = TWEET_URL_REGEX.exec(input);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
return urlMatch[1];
|
return urlMatch[1];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
|
||||||
|
|
||||||
export function normalizeHandle(input?: string | null): string | null {
|
export function normalizeHandle(input?: string | null): string | null {
|
||||||
const raw = (input ?? '').trim();
|
const raw = (input ?? '').trim();
|
||||||
if (!raw) return null;
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
|
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||||
const handle = withoutAt.trim();
|
const handle = withoutAt.trim();
|
||||||
if (!handle) return null;
|
if (!handle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
|
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
|
||||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null;
|
if (!HANDLE_REGEX.test(handle)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
} {
|
} {
|
||||||
if (typeof userOption === 'undefined') return { query: null, error: null };
|
if (typeof userOption === 'undefined') {
|
||||||
|
return { query: null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
const handle = normalizeHandle(userOption);
|
const handle = normalizeHandle(userOption);
|
||||||
if (!handle) {
|
if (!handle) {
|
||||||
|
|||||||
+18
-6
@@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
|
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
|
||||||
if (cfg.plain) return `${STATUS[kind].plain} `;
|
if (cfg.plain) {
|
||||||
if (cfg.emoji) return `${STATUS[kind].emoji} `;
|
return `${STATUS[kind].plain} `;
|
||||||
|
}
|
||||||
|
if (cfg.emoji) {
|
||||||
|
return `${STATUS[kind].emoji} `;
|
||||||
|
}
|
||||||
return `${STATUS[kind].text} `;
|
return `${STATUS[kind].text} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
|
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
|
||||||
if (cfg.plain) return `${LABELS[kind].plain} `;
|
if (cfg.plain) {
|
||||||
if (cfg.emoji) return `${LABELS[kind].emoji} `;
|
return `${LABELS[kind].plain} `;
|
||||||
|
}
|
||||||
|
if (cfg.emoji) {
|
||||||
|
return `${LABELS[kind].emoji} `;
|
||||||
|
}
|
||||||
return `${LABELS[kind].text} `;
|
return `${LABELS[kind].text} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +80,12 @@ export function formatStatsLine(
|
|||||||
const retweetCount = stats.retweetCount ?? 0;
|
const retweetCount = stats.retweetCount ?? 0;
|
||||||
const replyCount = stats.replyCount ?? 0;
|
const replyCount = stats.replyCount ?? 0;
|
||||||
|
|
||||||
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
|
if (cfg.plain) {
|
||||||
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
|
return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
|
||||||
|
}
|
||||||
|
if (!cfg.emoji) {
|
||||||
|
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
|
||||||
|
}
|
||||||
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
|
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
|
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
|
||||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const DISCOVERY_PAGES = [
|
const DISCOVERY_PAGES: string[] = [
|
||||||
'https://x.com/?lang=en',
|
'https://x.com/?lang=en',
|
||||||
'https://x.com/explore',
|
'https://x.com/explore',
|
||||||
'https://x.com/notifications',
|
'https://x.com/notifications',
|
||||||
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
||||||
|
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
const OPERATION_PATTERNS = [
|
const OPERATION_PATTERNS = [
|
||||||
{
|
{
|
||||||
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
|
|||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RuntimeQueryIdStore = {
|
||||||
|
cachePath: string;
|
||||||
|
ttlMs: number;
|
||||||
|
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
|
||||||
|
getQueryId: (operationName: string) => Promise<string | null>;
|
||||||
|
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
|
||||||
|
clearMemory: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
|
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
|
||||||
const response = await fetchImpl(url, { headers: HEADERS });
|
const response = await fetchImpl(url, { headers: HEADERS });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
|
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
|
||||||
if (!raw || typeof raw !== 'object') return null;
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const record = raw as Record<string, unknown>;
|
const record = raw as Record<string, unknown>;
|
||||||
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
|
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
|
||||||
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
|
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
|
||||||
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
|
|||||||
const discovery =
|
const discovery =
|
||||||
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
|
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
|
||||||
|
|
||||||
if (!fetchedAt || !ttlMs || !ids || !discovery) return null;
|
if (!fetchedAt || !ttlMs || !ids || !discovery) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
|
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
|
||||||
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
|
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
|
||||||
if (!pages || !bundles) return null;
|
if (!pages || !bundles) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedIds: Record<string, string> = {};
|
const normalizedIds: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(ids)) {
|
for (const [key, value] of Object.entries(ids)) {
|
||||||
@@ -122,7 +138,9 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
|
|||||||
return parseSnapshot(JSON.parse(raw));
|
return parseSnapshot(JSON.parse(raw));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
|
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
|
||||||
if (code !== 'ENOENT') return null;
|
if (code !== 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,15 +179,27 @@ function extractOperations(
|
|||||||
pattern.regex.lastIndex = 0;
|
pattern.regex.lastIndex = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const match = pattern.regex.exec(bundleContents);
|
const match = pattern.regex.exec(bundleContents);
|
||||||
if (match === null) break;
|
if (match === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
const operationName = match[pattern.operationGroup];
|
const operationName = match[pattern.operationGroup];
|
||||||
const queryId = match[pattern.queryIdGroup];
|
const queryId = match[pattern.queryIdGroup];
|
||||||
if (!operationName || !queryId) continue;
|
if (!operationName || !queryId) {
|
||||||
if (!targets.has(operationName)) continue;
|
continue;
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue;
|
}
|
||||||
if (discovered.has(operationName)) continue;
|
if (!targets.has(operationName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!QUERY_ID_REGEX.test(queryId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (discovered.has(operationName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
discovered.set(operationName, { queryId, bundle: bundleLabel });
|
discovered.set(operationName, { queryId, bundle: bundleLabel });
|
||||||
if (discovered.size === targets.size) return;
|
if (discovered.size === targets.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +216,9 @@ async function fetchAndExtract(
|
|||||||
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
|
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
chunk.map(async (url) => {
|
chunk.map(async (url) => {
|
||||||
if (discovered.size === targets.size) return;
|
if (discovered.size === targets.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const label = url.split('/').at(-1) ?? url;
|
const label = url.split('/').at(-1) ?? url;
|
||||||
try {
|
try {
|
||||||
const js = await fetchText(fetchImpl, url);
|
const js = await fetchText(fetchImpl, url);
|
||||||
@@ -196,13 +228,15 @@ async function fetchAndExtract(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (discovered.size === targets.size) break;
|
if (discovered.size === targets.size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return discovered;
|
return discovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) {
|
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
|
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
|
||||||
@@ -212,7 +246,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
|||||||
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
|
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
|
||||||
|
|
||||||
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
|
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
|
||||||
if (memorySnapshot) return memorySnapshot;
|
if (memorySnapshot) {
|
||||||
|
return memorySnapshot;
|
||||||
|
}
|
||||||
if (!loadOnce) {
|
if (!loadOnce) {
|
||||||
loadOnce = (async () => {
|
loadOnce = (async () => {
|
||||||
const fromDisk = await readSnapshotFromDisk(cachePath);
|
const fromDisk = await readSnapshotFromDisk(cachePath);
|
||||||
@@ -225,7 +261,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
|||||||
|
|
||||||
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||||
const snapshot = await loadSnapshot();
|
const snapshot = await loadSnapshot();
|
||||||
if (!snapshot) return null;
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
|
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
|
||||||
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
|
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
|
||||||
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
|
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
|
||||||
@@ -235,7 +273,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
|||||||
|
|
||||||
const getQueryId = async (operationName: string): Promise<string | null> => {
|
const getQueryId = async (operationName: string): Promise<string | null> => {
|
||||||
const info = await getSnapshotInfo();
|
const info = await getSnapshotInfo();
|
||||||
if (!info) return null;
|
if (!info) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return info.snapshot.ids[operationName] ?? null;
|
return info.snapshot.ids[operationName] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,7 +283,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
|||||||
operationNames: string[],
|
operationNames: string[],
|
||||||
opts: { force?: boolean } = {},
|
opts: { force?: boolean } = {},
|
||||||
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||||
if (refreshInFlight) return refreshInFlight;
|
if (refreshInFlight) {
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
refreshInFlight = (async () => {
|
refreshInFlight = (async () => {
|
||||||
const current = await getSnapshotInfo();
|
const current = await getSnapshotInfo();
|
||||||
|
|||||||
+112
-38
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
import type { TwitterCookies } from './cookies.js';
|
import type { TwitterCookies } from './cookies.js';
|
||||||
|
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
|
||||||
import queryIds from './query-ids.json' with { type: 'json' };
|
import queryIds from './query-ids.json' with { type: 'json' };
|
||||||
import { runtimeQueryIds } from './runtime-query-ids.js';
|
import { runtimeQueryIds } from './runtime-query-ids.js';
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
|
|||||||
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
|
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
|
||||||
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
|
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
|
||||||
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
|
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
|
||||||
|
const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
|
||||||
|
const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
|
||||||
|
const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/;
|
||||||
|
|
||||||
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
||||||
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||||
@@ -339,7 +343,9 @@ export class TwitterClient {
|
|||||||
| undefined,
|
| undefined,
|
||||||
tweetId: string,
|
tweetId: string,
|
||||||
) {
|
) {
|
||||||
if (!instructions) return undefined;
|
if (!instructions) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
for (const instruction of instructions) {
|
for (const instruction of instructions) {
|
||||||
for (const entry of instruction.entries || []) {
|
for (const entry of instruction.entries || []) {
|
||||||
@@ -401,10 +407,14 @@ export class TwitterClient {
|
|||||||
|
|
||||||
private mediaCategoryForMime(mimeType: string): string | null {
|
private mediaCategoryForMime(mimeType: string): string | null {
|
||||||
if (mimeType.startsWith('image/')) {
|
if (mimeType.startsWith('image/')) {
|
||||||
if (mimeType === 'image/gif') return 'tweet_gif';
|
if (mimeType === 'image/gif') {
|
||||||
|
return 'tweet_gif';
|
||||||
|
}
|
||||||
return 'tweet_image';
|
return 'tweet_image';
|
||||||
}
|
}
|
||||||
if (mimeType.startsWith('video/')) return 'tweet_video';
|
if (mimeType.startsWith('video/')) {
|
||||||
|
return 'tweet_video';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +424,9 @@ export class TwitterClient {
|
|||||||
|
|
||||||
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
|
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
|
||||||
const category = this.mediaCategoryForMime(input.mimeType);
|
const category = this.mediaCategoryForMime(input.mimeType);
|
||||||
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` };
|
if (!category) {
|
||||||
|
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initParams = new URLSearchParams({
|
const initParams = new URLSearchParams({
|
||||||
@@ -442,7 +454,9 @@ export class TwitterClient {
|
|||||||
: initBody.media_id !== undefined
|
: initBody.media_id !== undefined
|
||||||
? String(initBody.media_id)
|
? String(initBody.media_id)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!mediaId) return { success: false, error: 'Media upload INIT did not return media_id' };
|
if (!mediaId) {
|
||||||
|
return { success: false, error: 'Media upload INIT did not return media_id' };
|
||||||
|
}
|
||||||
|
|
||||||
const chunkSize = 5 * 1024 * 1024;
|
const chunkSize = 5 * 1024 * 1024;
|
||||||
let segmentIndex = 0;
|
let segmentIndex = 0;
|
||||||
@@ -516,11 +530,15 @@ export class TwitterClient {
|
|||||||
error?: { message?: string; name?: string };
|
error?: { message?: string; name?: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if (!statusBody.processing_info) break;
|
if (!statusBody.processing_info) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
info.state = statusBody.processing_info.state;
|
info.state = statusBody.processing_info.state;
|
||||||
info.check_after_secs = statusBody.processing_info.check_after_secs;
|
info.check_after_secs = statusBody.processing_info.check_after_secs;
|
||||||
info.error = statusBody.processing_info.error;
|
info.error = statusBody.processing_info.error;
|
||||||
if (info.state === 'succeeded') break;
|
if (info.state === 'succeeded') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,15 +565,21 @@ export class TwitterClient {
|
|||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
|
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
|
||||||
if (!value) return;
|
if (!value) {
|
||||||
if (typeof value === 'string') return;
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
@@ -569,7 +593,9 @@ export class TwitterClient {
|
|||||||
if (keys.has(key)) {
|
if (keys.has(key)) {
|
||||||
if (typeof nested === 'string') {
|
if (typeof nested === 'string') {
|
||||||
const trimmed = nested.trim();
|
const trimmed = nested.trim();
|
||||||
if (trimmed) output.push(trimmed);
|
if (trimmed) {
|
||||||
|
output.push(trimmed);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +608,9 @@ export class TwitterClient {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (seen.has(value)) continue;
|
if (seen.has(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seen.add(value);
|
seen.add(value);
|
||||||
result.push(value);
|
result.push(value);
|
||||||
}
|
}
|
||||||
@@ -591,7 +619,9 @@ export class TwitterClient {
|
|||||||
|
|
||||||
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
|
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
|
||||||
const article = result?.article;
|
const article = result?.article;
|
||||||
if (!article) return undefined;
|
if (!article) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const articleResult = article.article_results?.result ?? article;
|
const articleResult = article.article_results?.result ?? article;
|
||||||
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
|
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
|
||||||
@@ -656,7 +686,9 @@ export class TwitterClient {
|
|||||||
|
|
||||||
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
|
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
|
||||||
const note = result?.note_tweet?.note_tweet_results?.result;
|
const note = result?.note_tweet?.note_tweet_results?.result;
|
||||||
if (!note) return undefined;
|
if (!note) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return this.firstText(
|
return this.firstText(
|
||||||
note.text,
|
note.text,
|
||||||
@@ -681,10 +713,14 @@ export class TwitterClient {
|
|||||||
const username = userLegacy?.screen_name ?? userCore?.screen_name;
|
const username = userLegacy?.screen_name ?? userCore?.screen_name;
|
||||||
const name = userLegacy?.name ?? userCore?.name ?? username;
|
const name = userLegacy?.name ?? userCore?.name ?? username;
|
||||||
const userId = userResult?.rest_id;
|
const userId = userResult?.rest_id;
|
||||||
if (!result?.rest_id || !username) return undefined;
|
if (!result?.rest_id || !username) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const text = this.extractTweetText(result);
|
const text = this.extractTweetText(result);
|
||||||
if (!text) return undefined;
|
if (!text) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: result.rest_id,
|
id: result.rest_id,
|
||||||
@@ -742,7 +778,9 @@ export class TwitterClient {
|
|||||||
}): GraphqlTweetResult[] {
|
}): GraphqlTweetResult[] {
|
||||||
const results: GraphqlTweetResult[] = [];
|
const results: GraphqlTweetResult[] = [];
|
||||||
const pushResult = (result?: GraphqlTweetResult) => {
|
const pushResult = (result?: GraphqlTweetResult) => {
|
||||||
if (result?.rest_id) results.push(result);
|
if (result?.rest_id) {
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = entry.content;
|
const content = entry.content;
|
||||||
@@ -809,7 +847,9 @@ export class TwitterClient {
|
|||||||
const results = this.collectTweetResultsFromEntry(entry);
|
const results = this.collectTweetResultsFromEntry(entry);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const mapped = this.mapTweetResult(result);
|
const mapped = this.mapTweetResult(result);
|
||||||
if (!mapped || seen.has(mapped.id)) continue;
|
if (!mapped || seen.has(mapped.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seen.add(mapped.id);
|
seen.add(mapped.id);
|
||||||
tweets.push(mapped);
|
tweets.push(mapped);
|
||||||
}
|
}
|
||||||
@@ -990,7 +1030,9 @@ export class TwitterClient {
|
|||||||
for (const instruction of instructions) {
|
for (const instruction of instructions) {
|
||||||
for (const entry of instruction.entries ?? []) {
|
for (const entry of instruction.entries ?? []) {
|
||||||
const result = entry.content?.itemContent?.tweet_results?.result;
|
const result = entry.content?.itemContent?.tweet_results?.result;
|
||||||
if (result?.rest_id !== tweetId) continue;
|
if (result?.rest_id !== tweetId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const articleResult = result.article?.article_results?.result;
|
const articleResult = result.article?.article_results?.result;
|
||||||
const title = this.firstText(articleResult?.title, result.article?.title);
|
const title = this.firstText(articleResult?.title, result.article?.title);
|
||||||
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
|
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
|
||||||
@@ -1352,12 +1394,16 @@ export class TwitterClient {
|
|||||||
|
|
||||||
if (data.errors && data.errors.length > 0) {
|
if (data.errors && data.errors.length > 0) {
|
||||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||||
if (fallback) return fallback;
|
if (fallback) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
return { success: false, error: this.formatErrors(data.errors) };
|
return { success: false, error: this.formatErrors(data.errors) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||||
if (tweetId) return { success: true, tweetId };
|
if (tweetId) {
|
||||||
|
return { success: true, tweetId };
|
||||||
|
}
|
||||||
|
|
||||||
return { success: false, error: 'Tweet created but no ID returned' };
|
return { success: false, error: 'Tweet created but no ID returned' };
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1421,9 @@ export class TwitterClient {
|
|||||||
|
|
||||||
if (data.errors && data.errors.length > 0) {
|
if (data.errors && data.errors.length > 0) {
|
||||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||||
if (fallback) return fallback;
|
if (fallback) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: this.formatErrors(data.errors),
|
error: this.formatErrors(data.errors),
|
||||||
@@ -1414,7 +1462,9 @@ export class TwitterClient {
|
|||||||
mediaIds?: string[];
|
mediaIds?: string[];
|
||||||
} | null {
|
} | null {
|
||||||
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
|
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
|
||||||
if (!text) return null;
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const reply = variables.reply;
|
const reply = variables.reply;
|
||||||
const inReplyToTweetId =
|
const inReplyToTweetId =
|
||||||
@@ -1486,7 +1536,9 @@ export class TwitterClient {
|
|||||||
const tweetId =
|
const tweetId =
|
||||||
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
|
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
|
||||||
|
|
||||||
if (tweetId) return { success: true, tweetId };
|
if (tweetId) {
|
||||||
|
return { success: true, tweetId };
|
||||||
|
}
|
||||||
return { success: false, error: 'Tweet created but no ID returned' };
|
return { success: false, error: 'Tweet created but no ID returned' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
@@ -1497,12 +1549,18 @@ export class TwitterClient {
|
|||||||
errors: Array<{ message: string; code?: number }>,
|
errors: Array<{ message: string; code?: number }>,
|
||||||
variables: Record<string, unknown>,
|
variables: Record<string, unknown>,
|
||||||
): Promise<TweetResult | null> {
|
): Promise<TweetResult | null> {
|
||||||
if (!errors.some((error) => error.code === 226)) return null;
|
if (!errors.some((error) => error.code === 226)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
|
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
|
||||||
if (!input) return null;
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fallback = await this.postStatusUpdate(input);
|
const fallback = await this.postStatusUpdate(input);
|
||||||
if (fallback.success) return fallback;
|
if (fallback.success) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1511,8 +1569,12 @@ export class TwitterClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureClientUserId(): Promise<void> {
|
private async ensureClientUserId(): Promise<void> {
|
||||||
if (process.env.NODE_ENV === 'test') return;
|
if (process.env.NODE_ENV === 'test') {
|
||||||
if (this.clientUserId) return;
|
return;
|
||||||
|
}
|
||||||
|
if (this.clientUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await this.getCurrentUser();
|
const result = await this.getCurrentUser();
|
||||||
if (result.success && result.user?.id) {
|
if (result.success && result.user?.id) {
|
||||||
this.clientUserId = result.user.id;
|
this.clientUserId = result.user.id;
|
||||||
@@ -1622,12 +1684,16 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const firstAttempt = await tryOnce();
|
const firstAttempt = await tryOnce();
|
||||||
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets };
|
if (firstAttempt.success) {
|
||||||
|
return { success: true, tweets: firstAttempt.tweets };
|
||||||
|
}
|
||||||
|
|
||||||
if (firstAttempt.had404) {
|
if (firstAttempt.had404) {
|
||||||
await this.refreshQueryIds();
|
await this.refreshQueryIds();
|
||||||
const secondAttempt = await tryOnce();
|
const secondAttempt = await tryOnce();
|
||||||
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets };
|
if (secondAttempt.success) {
|
||||||
|
return { success: true, tweets: secondAttempt.tweets };
|
||||||
|
}
|
||||||
return { success: false, error: secondAttempt.error };
|
return { success: false, error: secondAttempt.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,9 +1795,9 @@ export class TwitterClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
const usernameMatch = html.match(/"screen_name":"([^"]+)"/);
|
const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html);
|
||||||
const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/);
|
const idMatch = SETTINGS_USER_ID_REGEX.exec(html);
|
||||||
const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
const nameMatch = SETTINGS_NAME_REGEX.exec(html);
|
||||||
|
|
||||||
const username = usernameMatch?.[1];
|
const username = usernameMatch?.[1];
|
||||||
const userId = idMatch?.[1];
|
const userId = idMatch?.[1];
|
||||||
@@ -1765,7 +1831,9 @@ export class TwitterClient {
|
|||||||
*/
|
*/
|
||||||
async getReplies(tweetId: string): Promise<SearchResult> {
|
async getReplies(tweetId: string): Promise<SearchResult> {
|
||||||
const response = await this.fetchTweetDetail(tweetId);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) return response;
|
if (!response.success) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||||
@@ -1779,7 +1847,9 @@ export class TwitterClient {
|
|||||||
*/
|
*/
|
||||||
async getThread(tweetId: string): Promise<SearchResult> {
|
async getThread(tweetId: string): Promise<SearchResult> {
|
||||||
const response = await this.fetchTweetDetail(tweetId);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) return response;
|
if (!response.success) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||||
@@ -1899,12 +1969,16 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const firstAttempt = await tryOnce();
|
const firstAttempt = await tryOnce();
|
||||||
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets };
|
if (firstAttempt.success) {
|
||||||
|
return { success: true, tweets: firstAttempt.tweets };
|
||||||
|
}
|
||||||
|
|
||||||
if (firstAttempt.had404) {
|
if (firstAttempt.had404) {
|
||||||
await this.refreshQueryIds();
|
await this.refreshQueryIds();
|
||||||
const secondAttempt = await tryOnce();
|
const secondAttempt = await tryOnce();
|
||||||
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets };
|
if (secondAttempt.success) {
|
||||||
|
return { success: true, tweets: secondAttempt.tweets };
|
||||||
|
}
|
||||||
return { success: false, error: secondAttempt.error };
|
return { success: false, error: secondAttempt.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-17
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
|
|||||||
declare const __dirname: string | undefined;
|
declare const __dirname: string | undefined;
|
||||||
|
|
||||||
export const FALLBACK_VERSION = 'unknown';
|
export const FALLBACK_VERSION = 'unknown';
|
||||||
|
const REF_PREFIX_REGEX = /^ref:\s*/i;
|
||||||
|
const LINE_SPLIT_REGEX = /\r?\n/;
|
||||||
|
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
|
||||||
|
|
||||||
function readPackageVersionFromJsonFile(candidate: string): string | null {
|
function readPackageVersionFromJsonFile(candidate: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname;
|
if (typeof __dirname === 'string' && __dirname.length > 0) {
|
||||||
|
return __dirname;
|
||||||
|
}
|
||||||
|
|
||||||
return process.cwd();
|
return process.cwd();
|
||||||
}
|
}
|
||||||
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
|
|||||||
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
|
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
|
||||||
? process.env.BIRD_VERSION.trim()
|
? process.env.BIRD_VERSION.trim()
|
||||||
: '';
|
: '';
|
||||||
if (injected.length > 0) return injected;
|
if (injected.length > 0) {
|
||||||
|
return injected;
|
||||||
|
}
|
||||||
|
|
||||||
let dir = resolveStartDir(importMetaUrl);
|
let dir = resolveStartDir(importMetaUrl);
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 10; i += 1) {
|
||||||
const version =
|
const version =
|
||||||
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
|
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
|
||||||
readVersionFromTextFile(path.join(dir, 'VERSION'));
|
readVersionFromTextFile(path.join(dir, 'VERSION'));
|
||||||
if (version) return version;
|
if (version) {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
const parent = path.dirname(dir);
|
const parent = path.dirname(dir);
|
||||||
if (parent === dir) break;
|
if (parent === dir) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
dir = parent;
|
dir = parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
return FALLBACK_VERSION;
|
return FALLBACK_VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateSha(sha: string, length = 8): string {
|
function truncateSha(sha: string, length: number = 8): string {
|
||||||
const trimmed = sha.trim();
|
const trimmed = sha.trim();
|
||||||
if (!trimmed) return '';
|
if (!trimmed) {
|
||||||
if (trimmed.length <= length) return trimmed;
|
return '';
|
||||||
|
}
|
||||||
|
if (trimmed.length <= length) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
return trimmed.slice(0, length);
|
return trimmed.slice(0, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!head) return null;
|
if (!head) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!head.startsWith('ref:')) {
|
if (!head.startsWith('ref:')) {
|
||||||
const sha = truncateSha(head);
|
const sha = truncateSha(head);
|
||||||
return sha.length > 0 ? sha : null;
|
return sha.length > 0 ? sha : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ref = head.replace(/^ref:\s*/i, '').trim();
|
const ref = head.replace(REF_PREFIX_REGEX, '').trim();
|
||||||
if (!ref) return null;
|
if (!ref) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const refPath = path.join(gitDir, ref);
|
const refPath = path.join(gitDir, ref);
|
||||||
try {
|
try {
|
||||||
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
|
|||||||
const packedRefsPath = path.join(gitDir, 'packed-refs');
|
const packedRefsPath = path.join(gitDir, 'packed-refs');
|
||||||
try {
|
try {
|
||||||
const packed = fs.readFileSync(packedRefsPath, 'utf8');
|
const packed = fs.readFileSync(packedRefsPath, 'utf8');
|
||||||
const lines = packed.split(/\r?\n/);
|
const lines = packed.split(LINE_SPLIT_REGEX);
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line || line.startsWith('#') || line.startsWith('^')) continue;
|
if (!line || line.startsWith('#') || line.startsWith('^')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const [shaRaw, refName] = line.split(' ');
|
const [shaRaw, refName] = line.split(' ');
|
||||||
if (refName?.trim() === ref) {
|
if (refName?.trim() === ref) {
|
||||||
const sha = truncateSha(shaRaw ?? '');
|
const sha = truncateSha(shaRaw ?? '');
|
||||||
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
|
|||||||
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
|
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
|
||||||
? process.env.BIRD_GIT_SHA.trim()
|
? process.env.BIRD_GIT_SHA.trim()
|
||||||
: '';
|
: '';
|
||||||
if (injected.length > 0) return truncateSha(injected);
|
if (injected.length > 0) {
|
||||||
|
return truncateSha(injected);
|
||||||
|
}
|
||||||
|
|
||||||
let dir = resolveStartDir(importMetaUrl);
|
let dir = resolveStartDir(importMetaUrl);
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 10; i += 1) {
|
||||||
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
|
|||||||
const stat = fs.statSync(dotGit);
|
const stat = fs.statSync(dotGit);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const sha = resolveGitShaFromGitDir(dotGit);
|
const sha = resolveGitShaFromGitDir(dotGit);
|
||||||
if (sha) return sha;
|
if (sha) {
|
||||||
|
return sha;
|
||||||
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
const txt = fs.readFileSync(dotGit, 'utf8');
|
const txt = fs.readFileSync(dotGit, 'utf8');
|
||||||
const match = txt.match(/gitdir:\s*(.+)\s*$/i);
|
const match = GITDIR_REGEX.exec(txt);
|
||||||
const gitDir = match?.[1]?.trim();
|
const gitDir = match?.[1]?.trim();
|
||||||
if (gitDir) {
|
if (gitDir) {
|
||||||
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
|
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
|
||||||
const sha = resolveGitShaFromGitDir(resolved);
|
const sha = resolveGitShaFromGitDir(resolved);
|
||||||
if (sha) return sha;
|
if (sha) {
|
||||||
|
return sha;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parent = path.dirname(dir);
|
const parent = path.dirname(dir);
|
||||||
if (parent === dir) break;
|
if (parent === dir) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
dir = parent;
|
dir = parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,57 @@ describe('cookies', () => {
|
|||||||
expect(result.cookies.ct0).toBe('test_ct0');
|
expect(result.cookies.ct0).toBe('test_ct0');
|
||||||
expect(result.cookies.source).toContain('Chrome');
|
expect(result.cookies.source).toContain('Chrome');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses default browser order when cookieSource is omitted', async () => {
|
||||||
|
sweet.results.set('safari', { cookies: [], warnings: [] });
|
||||||
|
sweet.results.set('chrome', { cookies: [], warnings: [] });
|
||||||
|
sweet.results.set('firefox', {
|
||||||
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
|
||||||
|
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({});
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBe('firefox_auth');
|
||||||
|
expect(result.cookies.ct0).toBe('firefox_ct0');
|
||||||
|
expect(result.cookies.source).toContain('Firefox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers twitter.com cookies when x.com is missing', async () => {
|
||||||
|
sweet.results.set('chrome', {
|
||||||
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'twitter_auth', domain: 'twitter.com' },
|
||||||
|
{ name: 'ct0', value: 'twitter_ct0', domain: 'twitter.com' },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({ cookieSource: 'chrome' });
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBe('twitter_auth');
|
||||||
|
expect(result.cookies.ct0).toBe('twitter_ct0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first cookie when no domain matches', async () => {
|
||||||
|
sweet.results.set('firefox', {
|
||||||
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'first_auth', domain: 'example.com' },
|
||||||
|
{ name: 'ct0', value: 'first_ct0', domain: 'example.com' },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({ cookieSource: 'firefox' });
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBe('first_auth');
|
||||||
|
expect(result.cookies.ct0).toBe('first_ct0');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractCookiesFromSafari', () => {
|
describe('extractCookiesFromSafari', () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
|
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
|
||||||
|
|
||||||
|
const INVALID_HANDLE_REGEX = /Invalid --user handle/;
|
||||||
|
|
||||||
describe('normalizeHandle', () => {
|
describe('normalizeHandle', () => {
|
||||||
it('accepts bare handle', () => {
|
it('accepts bare handle', () => {
|
||||||
expect(normalizeHandle('steipete')).toBe('steipete');
|
expect(normalizeHandle('steipete')).toBe('steipete');
|
||||||
@@ -36,6 +38,6 @@ describe('mentionsQueryFromUserOption', () => {
|
|||||||
it('returns error for invalid handle', () => {
|
it('returns error for invalid handle', () => {
|
||||||
const result = mentionsQueryFromUserOption('@stei-pete');
|
const result = mentionsQueryFromUserOption('@stei-pete');
|
||||||
expect(result.query).toBeNull();
|
expect(result.query).toBeNull();
|
||||||
expect(result.error).toMatch(/Invalid --user handle/);
|
expect(result.error).toMatch(INVALID_HANDLE_REGEX);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { mkdir, readFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
@@ -8,6 +8,7 @@ import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
|
|||||||
describe('runtime-query-ids', () => {
|
describe('runtime-query-ids', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.BIRD_QUERY_IDS_CACHE;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
|
it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
|
||||||
@@ -60,4 +61,83 @@ describe('runtime-query-ids', () => {
|
|||||||
const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
|
const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
|
||||||
expect(parsed.ids?.SearchTimeline).toBe('EEE');
|
expect(parsed.ids?.SearchTimeline).toBe('EEE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses env cache path override and returns fresh snapshot without refreshing', async () => {
|
||||||
|
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
const cachePath = path.join(cacheDir, 'query-ids-cache.json');
|
||||||
|
process.env.BIRD_QUERY_IDS_CACHE = cachePath;
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
ttlMs: 60_000,
|
||||||
|
ids: { CreateTweet: 'AAA' },
|
||||||
|
discovery: { pages: ['https://x.com/'], bundles: ['main.js'] },
|
||||||
|
};
|
||||||
|
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
const store = createRuntimeQueryIdStore({ fetchImpl: fetchMock as unknown as typeof fetch });
|
||||||
|
const info = await store.refresh(['CreateTweet']);
|
||||||
|
|
||||||
|
expect(store.cachePath).toBe(cachePath);
|
||||||
|
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
store.clearMemory();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns current snapshot when refresh finds no matching operations', async () => {
|
||||||
|
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
const cachePath = path.join(cacheDir, 'query-ids.json');
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
fetchedAt: new Date(0).toISOString(),
|
||||||
|
ttlMs: 1,
|
||||||
|
ids: { CreateTweet: 'AAA' },
|
||||||
|
discovery: { pages: ['https://x.com/'], bundles: ['main.test.js'] },
|
||||||
|
};
|
||||||
|
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
|
||||||
|
|
||||||
|
const html = `<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>`;
|
||||||
|
const js = 'console.log("no ops here")';
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||||
|
const asString = String(url);
|
||||||
|
if (asString.startsWith('https://x.com/')) {
|
||||||
|
return new Response(html, { status: 200 });
|
||||||
|
}
|
||||||
|
if (asString.startsWith('https://abs.twimg.com/')) {
|
||||||
|
return new Response(js, { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createRuntimeQueryIdStore({
|
||||||
|
cachePath,
|
||||||
|
ttlMs: 1,
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await store.refresh(['CreateTweet']);
|
||||||
|
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when no bundles can be discovered', async () => {
|
||||||
|
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
const cachePath = path.join(cacheDir, 'query-ids.json');
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
|
||||||
|
|
||||||
|
const store = createRuntimeQueryIdStore({
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(store.refresh(['CreateTweet'], { force: true })).rejects.toThrow(
|
||||||
|
'No client bundles discovered; x.com layout may have changed.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
|
||||||
|
const validCookies = {
|
||||||
|
authToken: 'test_auth_token',
|
||||||
|
ct0: 'test_ct0_token',
|
||||||
|
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
|
||||||
|
source: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponseLike = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
text: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TwitterClientApiPrivate = TwitterClient & {
|
||||||
|
getBookmarksQueryIds: () => Promise<string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async (): Promise<unknown> => ({}),
|
||||||
|
text: async (): Promise<string> => '',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient API coverage', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createTweet fallback paths', () => {
|
||||||
|
it('returns an error when retry response is not ok', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('hi');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns API errors when retry response contains errors', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ errors: [{ message: 'rate limited', code: 1 }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('hi');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('rate limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when retry response has no tweet id', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ data: { create_tweet: { tweet_results: { result: {} } } } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('hi');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('no ID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search error paths', () => {
|
||||||
|
it('returns an error for non-ok responses', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.search('test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns errors from payloads', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ errors: [{ message: 'bad' }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.search('test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('bad');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when fetching throws', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.search('test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the second attempt error after 404s', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.search('test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('HTTP 404');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bookmarks error paths', () => {
|
||||||
|
it('returns an error for non-ok responses', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns API errors from payloads', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ errors: [{ message: 'bad' }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('bad');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when fetching throws', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown error when no query ids are available', async () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getBookmarksQueryIds = async () => [];
|
||||||
|
|
||||||
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Unknown error fetching bookmarks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the second attempt error after 404s', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('HTTP 404');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
|
||||||
|
const validCookies = {
|
||||||
|
authToken: 'test_auth_token',
|
||||||
|
ct0: 'test_ct0_token',
|
||||||
|
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
|
||||||
|
source: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponseLike = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
text: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TwitterClientPrivate = TwitterClient & {
|
||||||
|
refreshQueryIds: () => Promise<void>;
|
||||||
|
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
|
||||||
|
ensureClientUserId: () => Promise<void>;
|
||||||
|
getBaseHeaders: () => Record<string, string>;
|
||||||
|
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
|
||||||
|
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
|
||||||
|
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async (): Promise<unknown> => ({}),
|
||||||
|
text: async (): Promise<string> => '',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient core coverage', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalNodeEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
process.env.NODE_ENV = originalNodeEnv;
|
||||||
|
delete process.env.BIRD_DEBUG_ARTICLE;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshQueryIds calls runtime refresh outside test env', async () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null);
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
await clientPrivate.refreshQueryIds();
|
||||||
|
|
||||||
|
expect(refreshSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchWithTimeout uses an abort signal when timeout is set', async () => {
|
||||||
|
const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
return makeResponse();
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies, timeoutMs: 5 });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
await clientPrivate.fetchWithTimeout('https://example.com', { method: 'GET' });
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a not found error when tweet data is missing', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
threaded_conversation_with_injections_v2: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: 'other',
|
||||||
|
legacy: { full_text: 'nope', created_at: '2024-01-01T00:00:00Z' },
|
||||||
|
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getTweet('123');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Tweet not found in response');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds client user id header after ensureClientUserId', async () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
vi.spyOn(client, 'getCurrentUser').mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
user: { id: '42', username: 'tester', name: 'Tester' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
await clientPrivate.ensureClientUserId();
|
||||||
|
const headers = clientPrivate.getBaseHeaders();
|
||||||
|
|
||||||
|
expect(headers['x-twitter-client-user-id']).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchUserArticlePlainText failures', () => {
|
||||||
|
it('returns empty object when response is not ok', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when fetching throws', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when no matching tweet is found', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: { result: { timeline: { timeline: { instructions: [] } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchTweetDetail error handling', () => {
|
||||||
|
it('returns API errors from response payloads', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ errors: [{ message: 'bad news' }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
const result = await clientPrivate.fetchTweetDetail('1');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('bad news');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses POST responses when GET returns 404', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
tweetResult: {
|
||||||
|
result: {
|
||||||
|
rest_id: '1',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'hi',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
reply_count: 0,
|
||||||
|
retweet_count: 0,
|
||||||
|
favorite_count: 0,
|
||||||
|
},
|
||||||
|
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
const result = await clientPrivate.fetchTweetDetail('1');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when fetch throws', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
const result = await clientPrivate.fetchTweetDetail('1');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('boom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postStatusUpdate error handling', () => {
|
||||||
|
it('returns an error when postStatusUpdate throws', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
const result = await clientPrivate.postStatusUpdate({ text: 'hello' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('boom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
|
||||||
|
const validCookies = {
|
||||||
|
authToken: 'test_auth_token',
|
||||||
|
ct0: 'test_ct0_token',
|
||||||
|
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
|
||||||
|
source: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponseLike = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
text: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TwitterClientUploadPrivate = TwitterClient & {
|
||||||
|
sleep: (ms: number) => Promise<void>;
|
||||||
|
extractArticleText: (result: unknown) => string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async (): Promise<unknown> => ({}),
|
||||||
|
text: async (): Promise<string> => '',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient upload coverage', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
delete process.env.BIRD_DEBUG_ARTICLE;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadMedia error paths', () => {
|
||||||
|
it('rejects unsupported media types', async () => {
|
||||||
|
global.fetch = vi.fn() as unknown as typeof fetch;
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when INIT fails', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when APPEND fails', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when FINALIZE fails', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
|
||||||
|
.mockResolvedValueOnce(makeResponse())
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when media processing fails', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
|
||||||
|
.mockResolvedValueOnce(makeResponse())
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ processing_info: { state: 'failed', error: { message: 'processing failed' } } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('processing failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when STATUS fails', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
|
||||||
|
.mockResolvedValueOnce(makeResponse())
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
|
||||||
|
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 503');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries STATUS and continues when processing is pending', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
|
||||||
|
.mockResolvedValueOnce(makeResponse())
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
|
||||||
|
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when metadata upload fails', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
|
||||||
|
.mockResolvedValueOnce(makeResponse())
|
||||||
|
.mockResolvedValueOnce(makeResponse({ json: async () => ({}) }))
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when upload throws', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('boom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('article extraction edge cases', () => {
|
||||||
|
it('logs article payloads when debug flag is set', () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
process.env.BIRD_DEBUG_ARTICLE = '1';
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
|
||||||
|
const result = clientPrivate.extractArticleText({
|
||||||
|
rest_id: '1',
|
||||||
|
article: {
|
||||||
|
title: 'Title',
|
||||||
|
plain_text: 'Body',
|
||||||
|
article_results: { result: { title: 'Title', plain_text: 'Body' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('Title\n\nBody');
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops duplicate body when it matches the title', () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
|
||||||
|
const result = clientPrivate.extractArticleText({
|
||||||
|
rest_id: '1',
|
||||||
|
article: {
|
||||||
|
title: 'Same',
|
||||||
|
plain_text: 'Same',
|
||||||
|
article_results: { result: { title: 'Same', plain_text: 'Same' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('Same');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+59
-1
@@ -3,7 +3,13 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { FALLBACK_VERSION, formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js';
|
import {
|
||||||
|
FALLBACK_VERSION,
|
||||||
|
formatVersionLine,
|
||||||
|
getCliVersion,
|
||||||
|
resolveGitSha,
|
||||||
|
resolvePackageVersion,
|
||||||
|
} from '../src/lib/version.js';
|
||||||
|
|
||||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-'));
|
||||||
@@ -53,6 +59,17 @@ describe('getCliVersion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to VERSION file when package.json has no version', () => {
|
||||||
|
withTempDir((dir) => {
|
||||||
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({}));
|
||||||
|
fs.writeFileSync(path.join(dir, 'VERSION'), '5.5.5\n');
|
||||||
|
const entry = path.join(dir, 'entry.ts');
|
||||||
|
fs.writeFileSync(entry, '// noop');
|
||||||
|
|
||||||
|
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('5.5.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to unknown when no version files exist', () => {
|
it('falls back to unknown when no version files exist', () => {
|
||||||
withTempDir((dir) => {
|
withTempDir((dir) => {
|
||||||
const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts');
|
const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts');
|
||||||
@@ -63,6 +80,31 @@ describe('getCliVersion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns null when git HEAD is missing', () => {
|
||||||
|
withTempDir((dir) => {
|
||||||
|
const gitDir = path.join(dir, '.git');
|
||||||
|
fs.mkdirSync(gitDir, { recursive: true });
|
||||||
|
|
||||||
|
const entry = path.join(dir, 'entry.ts');
|
||||||
|
fs.writeFileSync(entry, '// noop');
|
||||||
|
|
||||||
|
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when git ref cannot be resolved', () => {
|
||||||
|
withTempDir((dir) => {
|
||||||
|
const gitDir = path.join(dir, '.git');
|
||||||
|
fs.mkdirSync(gitDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n');
|
||||||
|
|
||||||
|
const entry = path.join(dir, 'entry.ts');
|
||||||
|
fs.writeFileSync(entry, '// noop');
|
||||||
|
|
||||||
|
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves git sha from .git directory HEAD', () => {
|
it('resolves git sha from .git directory HEAD', () => {
|
||||||
withTempDir((dir) => {
|
withTempDir((dir) => {
|
||||||
const gitDir = path.join(dir, '.git');
|
const gitDir = path.join(dir, '.git');
|
||||||
@@ -113,6 +155,16 @@ describe('getCliVersion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns null when no git directory exists', () => {
|
||||||
|
withTempDir((dir) => {
|
||||||
|
const entry = path.join(dir, 'nested', 'entry.ts');
|
||||||
|
fs.mkdirSync(path.dirname(entry), { recursive: true });
|
||||||
|
fs.writeFileSync(entry, '// noop');
|
||||||
|
|
||||||
|
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('formats version line with version + sha when both available', () => {
|
it('formats version line with version + sha when both available', () => {
|
||||||
withTempDir((dir) => {
|
withTempDir((dir) => {
|
||||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' }));
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' }));
|
||||||
@@ -126,4 +178,10 @@ describe('getCliVersion', () => {
|
|||||||
expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)');
|
expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns a CLI version string', () => {
|
||||||
|
const value = getCliVersion();
|
||||||
|
expect(typeof value).toBe('string');
|
||||||
|
expect(value.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -11,10 +11,10 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.ts'],
|
include: ['src/**/*.ts'],
|
||||||
exclude: ['src/index.ts'],
|
exclude: ['src/index.ts'],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 70,
|
statements: 90,
|
||||||
branches: 70,
|
branches: 80,
|
||||||
functions: 70,
|
functions: 90,
|
||||||
lines: 70,
|
lines: 90,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user