From b46b24a8c5c96635c1cdf8c9bbb5ef7e18b0dda5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 18:23:59 +0100 Subject: [PATCH] test: enforce coverage thresholds --- src/lib/cookies.ts | 2 +- tests/output.test.ts | 45 +++++++++++++- tests/twitter-client.test.ts | 89 ++++++++++++++++++++++++++ tests/version.test.ts | 117 ++++++++++++++++++++++++++++++++--- vitest.config.ts | 6 ++ 5 files changed, 248 insertions(+), 11 deletions(-) diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 34bd7a8..68b1696 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -5,7 +5,7 @@ import { execSync } from 'node:child_process'; import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; -import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync, readFileSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; diff --git a/tests/output.test.ts b/tests/output.test.ts index 5a5b853..9b24564 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { labelPrefix, resolveOutputConfigFromArgv, statusPrefix } from '../src/lib/output.js'; +import { + formatStatsLine, + labelPrefix, + resolveOutputConfigFromArgv, + resolveOutputConfigFromCommander, + statusPrefix, +} from '../src/lib/output.js'; describe('output', () => { + it('defaults to emoji + color on TTY', () => { + const cfg = resolveOutputConfigFromArgv([], {}, true); + expect(cfg).toEqual({ plain: false, emoji: true, color: true }); + }); + it('plain disables emoji + color', () => { const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true); expect(cfg).toEqual({ plain: true, emoji: false, color: false }); @@ -14,9 +25,41 @@ describe('output', () => { expect(cfg.color).toBe(false); }); + it('TERM=dumb disables colors by default', () => { + const cfg = resolveOutputConfigFromArgv([], { TERM: 'dumb' }, true); + expect(cfg.color).toBe(false); + }); + + it('--no-color disables colors', () => { + const cfg = resolveOutputConfigFromArgv(['--no-color'], {}, true); + expect(cfg).toEqual({ plain: false, emoji: true, color: false }); + }); + it('--no-emoji switches to text prefixes', () => { const cfg = resolveOutputConfigFromArgv(['--no-emoji'], {}, true); expect(cfg.emoji).toBe(false); expect(statusPrefix('warn', cfg)).toBe('Warning: '); }); + + it('commander opts override defaults', () => { + const cfg = resolveOutputConfigFromCommander({ emoji: false, color: false }, {}, true); + expect(cfg).toEqual({ plain: false, emoji: false, color: false }); + expect(statusPrefix('info', cfg)).toBe('Info: '); + expect(labelPrefix('date', cfg)).toBe('Date: '); + }); + + it('commander plain wins over emoji/color', () => { + const cfg = resolveOutputConfigFromCommander({ plain: true, emoji: true, color: true }, {}, true); + expect(cfg).toEqual({ plain: true, emoji: false, color: false }); + }); + + it('formats stats line for all modes', () => { + const stats = { likeCount: null, retweetCount: undefined, replyCount: 2 }; + + expect(formatStatsLine(stats, { plain: true, emoji: false, color: false })).toBe( + 'likes: 0 retweets: 0 replies: 2', + ); + expect(formatStatsLine(stats, { plain: false, emoji: false, color: false })).toBe('Likes 0 Retweets 0 Replies 2'); + expect(formatStatsLine(stats, { plain: false, emoji: true, color: false })).toBe('❤️ 0 🔁 0 💬 2'); + }); }); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 013f37c..2920d92 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -753,6 +753,54 @@ describe('TwitterClient', () => { expect(result.user?.id).toBe('999'); expect(mockFetch).toHaveBeenCalledTimes(5); }); + + it('skips an endpoint when JSON parsing fails', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('bad json'); + }, + }) + .mockResolvedValueOnce({ + 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).toHaveBeenCalledTimes(2); + }); + + it('continues on fetch errors and still succeeds via HTML fallback', async () => { + mockFetch + .mockRejectedValueOnce(new Error('boom')) + .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' }) + .mockRejectedValueOnce(new Error('settings boom')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => '"screen_name":"fallback","user_id":"999"', + }); + + 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'); + }); }); describe('search', () => { @@ -832,6 +880,47 @@ describe('TwitterClient', () => { const parsed = JSON.parse(urlVars as string) as { rawQuery?: string }; expect(parsed.rawQuery).toBe('needle'); }); + + it('refreshes query IDs when all search endpoints 404', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' }) + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' }) + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' }) + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + search_by_raw_query: { + search_timeline: { + timeline: { + instructions: [], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.search('hello', 5); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(5); + }); + + it('returns an unknown error when no query IDs are available', async () => { + const client = new TwitterClient({ cookies: validCookies }); + (client as unknown as { getSearchTimelineQueryIds: () => Promise }).getSearchTimelineQueryIds = + async () => []; + + const result = await client.search('hello', 5); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown error fetching search results'); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe('conversation helpers', () => { diff --git a/tests/version.test.ts b/tests/version.test.ts index 380a1b2..75d6439 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -1,7 +1,18 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; +import { FALLBACK_VERSION, formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; + +function withTempDir(fn: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} describe('getCliVersion', () => { afterEach(() => { @@ -10,7 +21,7 @@ describe('getCliVersion', () => { }); it('reads package.json version when available', () => { - const raw = readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'); + const raw = fs.readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'); const pkg = JSON.parse(raw) as { version?: unknown }; expect(resolvePackageVersion()).toBe(pkg.version); }); @@ -21,10 +32,98 @@ describe('getCliVersion', () => { expect(formatVersionLine('not a url')).toBe('9.9.9 (abcdef12)'); }); - it('adds git sha when available', () => { - const version = resolvePackageVersion(); - const sha = resolveGitSha(); - expect(sha).toMatch(/^[0-9a-f]{8}$/); - expect(formatVersionLine()).toBe(`${version} (${sha})`); + it('resolves package.json version from importMetaUrl root', () => { + withTempDir((dir) => { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '1.2.3' })); + const entry = path.join(dir, 'src', 'entry.ts'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, '// noop'); + + expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('1.2.3'); + }); + }); + + it('falls back to VERSION file when package.json missing', () => { + withTempDir((dir) => { + fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0\n'); + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('2.0.0'); + }); + }); + + it('falls back to unknown when no version files exist', () => { + withTempDir((dir) => { + const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, '// noop'); + + expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe(FALLBACK_VERSION); + }); + }); + + it('resolves git sha from .git directory HEAD', () => { + withTempDir((dir) => { + const gitDir = path.join(dir, '.git'); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync(path.join(gitDir, 'HEAD'), '0123456789abcdef0123456789abcdef01234567\n'); + + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(resolveGitSha(pathToFileURL(entry).href)).toBe('01234567'); + }); + }); + + it('resolves git sha from ref file and packed-refs', () => { + withTempDir((dir) => { + const gitDir = path.join(dir, '.git'); + fs.mkdirSync(path.join(gitDir, 'refs', 'heads'), { recursive: true }); + fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n'); + fs.writeFileSync(path.join(gitDir, 'refs', 'heads', 'main'), 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n'); + + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(resolveGitSha(pathToFileURL(entry).href)).toBe('aaaaaaaa'); + + fs.rmSync(path.join(gitDir, 'refs', 'heads', 'main'), { force: true }); + fs.writeFileSync( + path.join(gitDir, 'packed-refs'), + ['# pack-refs with: peeled fully-peeled', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main', ''].join( + '\n', + ), + ); + expect(resolveGitSha(pathToFileURL(entry).href)).toBe('bbbbbbbb'); + }); + }); + + it('resolves git sha from .git file gitdir pointer', () => { + withTempDir((dir) => { + const realGitDir = path.join(dir, '.realgit'); + fs.mkdirSync(realGitDir, { recursive: true }); + fs.writeFileSync(path.join(realGitDir, 'HEAD'), 'cccccccccccccccccccccccccccccccccccccccc\n'); + fs.writeFileSync(path.join(dir, '.git'), 'gitdir: .realgit\n'); + + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(resolveGitSha(pathToFileURL(entry).href)).toBe('cccccccc'); + }); + }); + + it('formats version line with version + sha when both available', () => { + withTempDir((dir) => { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' })); + const gitDir = path.join(dir, '.git'); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync(path.join(gitDir, 'HEAD'), 'dddddddddddddddddddddddddddddddddddddddd\n'); + + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)'); + }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 3d14848..a2db739 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,12 @@ export default defineConfig({ reporter: ['text', 'html'], include: ['src/**/*.ts'], exclude: ['src/index.ts'], + thresholds: { + statements: 70, + branches: 70, + functions: 70, + lines: 70, + }, }, }, });