feat: add config defaults, Firefox cookies, and Sweetistics whoami
This commit is contained in:
+4
-1
@@ -10,6 +10,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/index.ts",
|
||||
"bird": "pnpm run build && node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "biome check .",
|
||||
@@ -20,7 +21,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
"devalue": "^5.1.0"
|
||||
"devalue": "^5.1.0",
|
||||
"json5": "^2.2.3",
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
|
||||
Generated
+19
@@ -14,6 +14,12 @@ importers:
|
||||
devalue:
|
||||
specifier: ^5.1.0
|
||||
version: 5.5.0
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
kleur:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^1.9.4
|
||||
@@ -592,6 +598,15 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@@ -1130,6 +1145,10 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
|
||||
+183
-15
@@ -10,6 +10,11 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import JSON5 from 'json5';
|
||||
import kleur from 'kleur';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { resolveCredentials } from './lib/cookies.js';
|
||||
import { extractTweetId } from './lib/extract-tweet-id.js';
|
||||
import { TwitterClient, type TweetData } from './lib/twitter-client.js';
|
||||
@@ -17,19 +22,87 @@ import { SweetisticsClient } from './lib/sweetistics-client.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
const isTty = process.stdout.isTTY;
|
||||
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
|
||||
|
||||
const colors = {
|
||||
banner: wrap((t) => kleur.bold().blue(t)),
|
||||
subtitle: wrap((t) => kleur.dim(t)),
|
||||
section: wrap((t) => kleur.bold().white(t)),
|
||||
bullet: wrap((t) => kleur.blue(t)),
|
||||
command: wrap((t) => kleur.bold().cyan(t)),
|
||||
option: wrap((t) => kleur.cyan(t)),
|
||||
argument: wrap((t) => kleur.magenta(t)),
|
||||
description: wrap((t) => kleur.white(t)),
|
||||
muted: wrap((t) => kleur.gray(t)),
|
||||
accent: wrap((t) => kleur.green(t)),
|
||||
};
|
||||
|
||||
type BirdConfig = {
|
||||
engine?: EngineMode;
|
||||
chromeProfile?: string;
|
||||
firefoxProfile?: string;
|
||||
sweetisticsApiKey?: string;
|
||||
sweetisticsBaseUrl?: string;
|
||||
};
|
||||
|
||||
function readConfigFile(path: string): Partial<BirdConfig> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
|
||||
return parsed ?? {};
|
||||
} catch (error) {
|
||||
console.error(colors.muted(`⚠️ Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`));
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function loadConfig(): BirdConfig {
|
||||
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
|
||||
const localPath = join(process.cwd(), '.birdrc.json5');
|
||||
|
||||
return {
|
||||
...readConfigFile(globalPath),
|
||||
...readConfigFile(localPath),
|
||||
};
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
program.addHelpText('beforeAll', () => `${colors.banner('bird CLI')} ${colors.subtitle('— tweet from your terminal')}`);
|
||||
|
||||
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version('0.1.0');
|
||||
|
||||
const formatExample = (command: string, description: string) =>
|
||||
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
|
||||
|
||||
program.addHelpText(
|
||||
'afterAll',
|
||||
() => `\n${colors.section('Examples')}\n${[
|
||||
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
|
||||
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
|
||||
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
|
||||
formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'),
|
||||
].join('\n\n')}`,
|
||||
);
|
||||
|
||||
// Global options for authentication
|
||||
program
|
||||
.option('--auth-token <token>', 'Twitter auth_token cookie')
|
||||
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction')
|
||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', config.chromeProfile)
|
||||
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', config.firefoxProfile)
|
||||
.option('--sweetistics-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
|
||||
.option('--sweetistics-base-url <url>', 'Sweetistics base URL', process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com')
|
||||
.option(
|
||||
'--sweetistics-base-url <url>',
|
||||
'Sweetistics base URL',
|
||||
config.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com',
|
||||
)
|
||||
.option(
|
||||
'--engine <engine>',
|
||||
'Engine: graphql | sweetistics | auto',
|
||||
process.env.BIRD_ENGINE || 'auto',
|
||||
process.env.BIRD_ENGINE || config.engine || 'auto',
|
||||
);
|
||||
|
||||
type EngineMode = 'graphql' | 'sweetistics' | 'auto';
|
||||
@@ -92,7 +165,10 @@ program
|
||||
.argument('<text>', 'Tweet text')
|
||||
.action(async (text: string) => {
|
||||
const opts = program.opts();
|
||||
const sweetistics = resolveSweetisticsConfig(opts);
|
||||
const sweetistics = resolveSweetisticsConfig({
|
||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||
});
|
||||
const engine = resolveEngineMode(opts.engine);
|
||||
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||
|
||||
@@ -125,7 +201,8 @@ program
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
chromeProfile: opts.chromeProfile || config.chromeProfile,
|
||||
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
|
||||
});
|
||||
|
||||
for (const warning of warnings) {
|
||||
@@ -161,7 +238,10 @@ program
|
||||
.argument('<text>', 'Reply text')
|
||||
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||
const opts = program.opts();
|
||||
const sweetistics = resolveSweetisticsConfig(opts);
|
||||
const sweetistics = resolveSweetisticsConfig({
|
||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||
});
|
||||
const engine = resolveEngineMode(opts.engine);
|
||||
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
@@ -195,7 +275,8 @@ program
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
chromeProfile: opts.chromeProfile || config.chromeProfile,
|
||||
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
|
||||
});
|
||||
|
||||
for (const warning of warnings) {
|
||||
@@ -233,7 +314,10 @@ program
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const sweetistics = resolveSweetisticsConfig(opts);
|
||||
const sweetistics = resolveSweetisticsConfig({
|
||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||
});
|
||||
const engine = resolveEngineMode(opts.engine);
|
||||
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||
|
||||
@@ -267,7 +351,8 @@ program
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
chromeProfile: opts.chromeProfile || config.chromeProfile,
|
||||
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
|
||||
});
|
||||
|
||||
for (const warning of warnings) {
|
||||
@@ -309,7 +394,10 @@ program
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const sweetistics = resolveSweetisticsConfig(opts);
|
||||
const sweetistics = resolveSweetisticsConfig({
|
||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||
});
|
||||
const engine = resolveEngineMode(opts.engine);
|
||||
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
@@ -331,7 +419,8 @@ program
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
chromeProfile: opts.chromeProfile || config.chromeProfile,
|
||||
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
|
||||
});
|
||||
|
||||
for (const warning of warnings) {
|
||||
@@ -362,7 +451,10 @@ program
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const sweetistics = resolveSweetisticsConfig(opts);
|
||||
const sweetistics = resolveSweetisticsConfig({
|
||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||
});
|
||||
const engine = resolveEngineMode(opts.engine);
|
||||
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
@@ -384,7 +476,8 @@ program
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
chromeProfile: opts.chromeProfile || config.chromeProfile,
|
||||
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
|
||||
});
|
||||
|
||||
for (const warning of warnings) {
|
||||
@@ -417,7 +510,10 @@ program
|
||||
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const count = parseInt(cmdOpts.count || '10', 10);
|
||||
const sweetistics = resolveSweetisticsConfig(opts);
|
||||
const sweetistics = resolveSweetisticsConfig({
|
||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||
});
|
||||
const engine = resolveEngineMode(opts.engine);
|
||||
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||
|
||||
@@ -439,7 +535,8 @@ program
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
chromeProfile: opts.chromeProfile || config.chromeProfile,
|
||||
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
|
||||
});
|
||||
|
||||
for (const warning of warnings) {
|
||||
@@ -516,6 +613,71 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Whoami command - show the logged-in account
|
||||
program
|
||||
.command('whoami')
|
||||
.description('Show which Twitter account the current credentials belong to')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
const sweetistics = resolveSweetisticsConfig(opts);
|
||||
const engine = resolveEngineMode(opts.engine);
|
||||
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||
|
||||
if (useSweetistics) {
|
||||
if (!sweetistics.apiKey) {
|
||||
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
if (result.success && result.user) {
|
||||
const handle = result.user.username ? `@${result.user.username}` : '(no handle)';
|
||||
const name = result.user.name || handle;
|
||||
console.log(`🙋 Logged in via Sweetistics as ${handle} (${name})`);
|
||||
console.log(`🪪 User ID: ${result.user.id}`);
|
||||
if (result.user.email) {
|
||||
console.log(`📧 ${result.user.email}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`❌ Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
});
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.error(`⚠️ ${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error('❌ Missing required credentials');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (cookies.source) {
|
||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
if (result.success && result.user) {
|
||||
console.log(`🙋 Logged in as @${result.user.username} (${result.user.name})`);
|
||||
console.log(`🪪 User ID: ${result.user.id}`);
|
||||
} else {
|
||||
console.error(`❌ Failed to determine current user: ${result.error ?? 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Check command - verify credentials
|
||||
program
|
||||
.command('check')
|
||||
@@ -565,4 +727,10 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Show help when invoked without any subcommand
|
||||
if (process.argv.length <= 2) {
|
||||
program.outputHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
program.parse();
|
||||
|
||||
+107
-3
@@ -5,7 +5,7 @@
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -34,6 +34,26 @@ function getChromeCookiesPath(profile?: string): string {
|
||||
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
|
||||
}
|
||||
|
||||
function getFirefoxCookiesPath(profile?: string): string | null {
|
||||
const home = process.env.HOME || '';
|
||||
const profilesRoot = join(home, 'Library', 'Application Support', 'Firefox', 'Profiles');
|
||||
if (!existsSync(profilesRoot)) return null;
|
||||
|
||||
if (profile) {
|
||||
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
// Pick the default-release profile if present, otherwise first profile dir containing cookies.sqlite
|
||||
const entries = readdirSync(profilesRoot, { withFileTypes: true });
|
||||
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.endsWith('.default-release'));
|
||||
const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name;
|
||||
if (!targetDir) return null;
|
||||
|
||||
const candidate = join(profilesRoot, targetDir, 'cookies.sqlite');
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt Chrome cookie value using macOS keychain
|
||||
* Chrome encrypts cookies with a key stored in the keychain
|
||||
@@ -178,6 +198,74 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
|
||||
return { cookies, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Twitter cookies from Firefox browser using sqlite3 CLI
|
||||
* @param profile - Firefox profile directory name (optional, auto-detected)
|
||||
*/
|
||||
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
|
||||
const warnings: string[] = [];
|
||||
const cookies: TwitterCookies = {
|
||||
authToken: null,
|
||||
ct0: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
const cookiesPath = getFirefoxCookiesPath(profile);
|
||||
|
||||
if (!cookiesPath) {
|
||||
warnings.push('Firefox cookies database not found.');
|
||||
return { cookies, warnings };
|
||||
}
|
||||
|
||||
let tempDir: string | null = null;
|
||||
|
||||
try {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
|
||||
const tempDbPath = join(tempDir, 'cookies.sqlite');
|
||||
copyFileSync(cookiesPath, tempDbPath);
|
||||
|
||||
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
|
||||
|
||||
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
}).trim();
|
||||
|
||||
if (result) {
|
||||
for (const line of result.split('\n')) {
|
||||
const [name, value] = line.split('|');
|
||||
if (!name || !value) continue;
|
||||
if (name === 'auth_token' && !cookies.authToken) {
|
||||
cookies.authToken = value;
|
||||
} else if (name === 'ct0' && !cookies.ct0) {
|
||||
cookies.ct0 = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cookies.authToken || cookies.ct0) {
|
||||
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
warnings.push(`Failed to read Firefox cookies: ${message}`);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookies.authToken && !cookies.ct0) {
|
||||
warnings.push('No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox.');
|
||||
}
|
||||
|
||||
return { cookies, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Twitter credentials from multiple sources
|
||||
* Priority: CLI args > environment variables > Chrome cookies
|
||||
@@ -186,6 +274,7 @@ export async function resolveCredentials(options: {
|
||||
authToken?: string;
|
||||
ct0?: string;
|
||||
chromeProfile?: string;
|
||||
firefoxProfile?: string;
|
||||
}): Promise<CookieExtractionResult> {
|
||||
const warnings: string[] = [];
|
||||
const cookies: TwitterCookies = {
|
||||
@@ -245,12 +334,27 @@ export async function resolveCredentials(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Firefox cookies (fallback if still missing)
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
|
||||
warnings.push(...firefoxResult.warnings);
|
||||
|
||||
if (!cookies.authToken && firefoxResult.cookies.authToken) {
|
||||
cookies.authToken = firefoxResult.cookies.authToken;
|
||||
cookies.source = firefoxResult.cookies.source;
|
||||
}
|
||||
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
|
||||
cookies.ct0 = firefoxResult.cookies.ct0;
|
||||
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!cookies.authToken) {
|
||||
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome');
|
||||
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox');
|
||||
}
|
||||
if (!cookies.ct0) {
|
||||
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome');
|
||||
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox');
|
||||
}
|
||||
|
||||
return { cookies, warnings };
|
||||
|
||||
@@ -39,6 +39,18 @@ export interface SweetisticsReadResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SweetisticsUserResult {
|
||||
success: boolean;
|
||||
user?: {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
profileImageUrl?: string | null;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type TrpcResponseEnvelope = { result?: { data?: unknown; error?: unknown } };
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
@@ -244,6 +256,80 @@ export class SweetisticsClient {
|
||||
return { success: true, tweets };
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<SweetisticsUserResult> {
|
||||
const headers = {
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
let response = await fetch(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ 0: { json: null } }),
|
||||
});
|
||||
|
||||
// Some deployments only allow GET for queries; fall back if POST is not allowed
|
||||
if (response.status === 405) {
|
||||
response = await fetch(`${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const envelope = Array.isArray(body) ? body[0] : body;
|
||||
const maybeError = envelope?.error?.message || envelope?.result?.error?.message;
|
||||
if (maybeError) {
|
||||
return { success: false, error: String(maybeError) };
|
||||
}
|
||||
|
||||
let data = envelope?.result?.data?.json ?? envelope?.result?.data ?? envelope?.json ?? envelope;
|
||||
|
||||
try {
|
||||
if (typeof data === 'string') {
|
||||
data = devalueParse(data);
|
||||
} else if (data && typeof data === 'object' && 'json' in (data as Record<string, unknown>)) {
|
||||
const inner = (data as Record<string, unknown>).json;
|
||||
data = typeof inner === 'string' ? devalueParse(inner) : inner;
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const user = data && typeof data === 'object' ? data : null;
|
||||
if (!user || typeof (user as any).id !== 'string') {
|
||||
return { success: false, error: 'Malformed user payload from Sweetistics' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: String((user as any).id),
|
||||
username: (user as any).username ?? (user as any).twitterUsername ?? null,
|
||||
name: (user as any).name ?? null,
|
||||
email: (user as any).email ?? null,
|
||||
profileImageUrl: (user as any).profileImageUrl ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchConversation(
|
||||
tweetId: string,
|
||||
options: { excludeRoot: boolean },
|
||||
|
||||
@@ -81,6 +81,16 @@ export interface SearchResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CurrentUserResult {
|
||||
success: boolean;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TwitterClientOptions {
|
||||
cookies: TwitterCookies;
|
||||
userAgent?: string;
|
||||
@@ -683,6 +693,130 @@ export class TwitterClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account associated with the current cookies
|
||||
*/
|
||||
async getCurrentUser(): Promise<CurrentUserResult> {
|
||||
const candidateUrls = [
|
||||
'https://x.com/i/api/account/settings.json',
|
||||
'https://api.twitter.com/1.1/account/settings.json',
|
||||
'https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false',
|
||||
'https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_entities=false',
|
||||
];
|
||||
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (const url of candidateUrls) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const username =
|
||||
typeof data?.screen_name === 'string'
|
||||
? data.screen_name
|
||||
: typeof data?.user?.screen_name === 'string'
|
||||
? data.user.screen_name
|
||||
: null;
|
||||
|
||||
const name =
|
||||
typeof data?.name === 'string'
|
||||
? data.name
|
||||
: typeof data?.user?.name === 'string'
|
||||
? data.user.name
|
||||
: username ?? '';
|
||||
|
||||
const userId =
|
||||
typeof data?.user_id === 'string'
|
||||
? data.user_id
|
||||
: typeof data?.user_id_str === 'string'
|
||||
? data.user_id_str
|
||||
: typeof data?.user?.id_str === 'string'
|
||||
? data.user.id_str
|
||||
: typeof data?.user?.id === 'string'
|
||||
? data.user.id
|
||||
: null;
|
||||
|
||||
if (username && userId) {
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: userId,
|
||||
username,
|
||||
name: name || username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
lastError = 'Could not determine current user from response';
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scrape the authenticated settings page (HTML) for screen_name/user_id
|
||||
const profilePages = ['https://x.com/settings/account', 'https://twitter.com/settings/account'];
|
||||
for (const page of profilePages) {
|
||||
try {
|
||||
const response = await fetch(page, {
|
||||
headers: {
|
||||
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`,
|
||||
'user-agent': this.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = `HTTP ${response.status} (settings page)`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const usernameMatch = html.match(/"screen_name":"([^"]+)"/);
|
||||
const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/);
|
||||
const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
||||
|
||||
const username = usernameMatch?.[1];
|
||||
const userId = idMatch?.[1];
|
||||
const name = nameMatch?.[1]?.replace(/\\"/g, '"');
|
||||
|
||||
if (username && userId) {
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: userId,
|
||||
username,
|
||||
name: name || username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
lastError = 'Could not parse settings page for user info';
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError ?? 'Unknown error fetching current user',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get replies to a tweet by ID
|
||||
*/
|
||||
|
||||
@@ -98,9 +98,11 @@ describe('cookies', () => {
|
||||
const result = await resolveCredentials({});
|
||||
|
||||
expect(result.warnings).toContain(
|
||||
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome',
|
||||
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox',
|
||||
);
|
||||
expect(result.warnings).toContain('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,12 +53,10 @@ describe('SweetisticsClient', () => {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '1',
|
||||
text: 'hi',
|
||||
author: { username: 'u', name: 'User' },
|
||||
},
|
||||
id: '1',
|
||||
text: 'hi',
|
||||
author: { username: 'u', name: 'User' },
|
||||
metrics: { likeCount: 5 },
|
||||
}),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
@@ -69,17 +67,35 @@ describe('SweetisticsClient', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tweet?.id).toBe('1');
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.example.com/api/twitter/tweet/1');
|
||||
expect(url).toBe('https://api.example.com/api/tweets/1');
|
||||
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
|
||||
});
|
||||
|
||||
it('fetches replies', async () => {
|
||||
const conversationPayload = {
|
||||
tweetIds: ['1', '2'],
|
||||
tweets: {
|
||||
1: {
|
||||
id: '1',
|
||||
text: 'root',
|
||||
author: { username: 'root', name: 'Root' },
|
||||
conversationId: 'c1',
|
||||
},
|
||||
2: {
|
||||
id: '2',
|
||||
text: 'reply',
|
||||
author: { username: 'reply', name: 'Reply' },
|
||||
conversationId: 'c1',
|
||||
inReplyToStatusId: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
tweets: [{ id: '2', text: 'reply', author: { username: 'r', name: 'Reply' } }],
|
||||
result: { data: conversationPayload },
|
||||
}),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
@@ -90,17 +106,24 @@ describe('SweetisticsClient', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tweets?.[0].id).toBe('2');
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.example.com/api/twitter/tweet/1/replies');
|
||||
expect(url).toContain('/api/trpc/tweets.getConversation');
|
||||
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
|
||||
});
|
||||
|
||||
it('fetches thread', async () => {
|
||||
const conversationPayload = {
|
||||
tweetIds: ['1', '2'],
|
||||
tweets: {
|
||||
1: { id: '1', text: 'root', author: { username: 'root', name: 'Root' }, conversationId: 'c1' },
|
||||
2: { id: '2', text: 'reply', author: { username: 'reply', name: 'Reply' }, conversationId: 'c1' },
|
||||
},
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
tweets: [{ id: '1', text: 'root', author: { username: 'u', name: 'User' } }],
|
||||
result: { data: conversationPayload },
|
||||
}),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
@@ -109,20 +132,32 @@ describe('SweetisticsClient', () => {
|
||||
const result = await client.thread('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tweets?.length).toBe(1);
|
||||
expect(result.tweets?.length).toBe(2);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.example.com/api/twitter/tweet/1/thread');
|
||||
expect(url).toContain('/api/trpc/tweets.getConversation');
|
||||
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
|
||||
});
|
||||
|
||||
it('searches tweets', async () => {
|
||||
const searchPayload = {
|
||||
tweets: {
|
||||
items: [
|
||||
{
|
||||
id: '3',
|
||||
text: 'needle',
|
||||
authorUsername: 'n',
|
||||
authorName: 'Needle',
|
||||
createdAt: '2024-01-01',
|
||||
metrics: { replyCount: 1, retweetCount: 2, likeCount: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
tweets: [{ id: '3', text: 'needle', author: { username: 'n', name: 'Needle' } }],
|
||||
}),
|
||||
json: async () => [{ result: { data: searchPayload } }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
@@ -131,22 +166,76 @@ describe('SweetisticsClient', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tweets?.[0].id).toBe('3');
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.example.com/api/twitter/search?q=needle&count=5');
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('/api/trpc/search.execute');
|
||||
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
|
||||
});
|
||||
|
||||
it('propagates Sweetistics error message', async () => {
|
||||
it('fetches current user', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ success: false, error: 'boom' }),
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [{ result: { data: { json: { id: 'u1', username: 'tester', name: 'Test User' } } } }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
|
||||
const result = await client.read('1');
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('u1');
|
||||
expect(result.user?.username).toBe('tester');
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.example.com/api/trpc/user.getCurrent?batch=1');
|
||||
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
|
||||
});
|
||||
|
||||
it('falls back to GET when POST is not allowed', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 405, json: async () => ({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ result: { data: { json: { id: 'u2', username: 'fallback' } } } }),
|
||||
});
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('u2');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('input=null');
|
||||
});
|
||||
|
||||
it('propagates Sweetistics user errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [{ result: { error: { message: 'bad key' } } }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('bad key');
|
||||
});
|
||||
|
||||
it('propagates Sweetistics error message', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 500,
|
||||
json: async () => [{ error: { message: 'boom' } }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
|
||||
const result = await client.search('needle', 1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('boom');
|
||||
|
||||
@@ -296,4 +296,82 @@ describe('TwitterClient', () => {
|
||||
expect(result.error).toContain('HTTP 404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as any;
|
||||
});
|
||||
|
||||
it('returns mapped user details when present', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
user_id: '12345',
|
||||
screen_name: 'tester',
|
||||
name: 'Test User',
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toEqual({ id: '12345', username: 'tester', name: 'Test User' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('account/settings'), expect.any(Object));
|
||||
});
|
||||
|
||||
it('returns error when response lacks identifiers', async () => {
|
||||
mockFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ language: 'en' }),
|
||||
text: async () => '{"language":"en"}',
|
||||
}));
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Could not');
|
||||
});
|
||||
|
||||
it('surfaces HTTP errors', async () => {
|
||||
mockFetch.mockImplementation(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'Unauthorized',
|
||||
}));
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('HTTP 401');
|
||||
});
|
||||
|
||||
it('uses HTML fallback when API endpoints 404', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '<html>"screen_name":"fallback","user_id":"999"</html>',
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.username).toBe('fallback');
|
||||
expect(result.user?.id).toBe('999');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user