feat: add runtime feature overrides
This commit is contained in:
@@ -7,6 +7,9 @@
|
|||||||
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
|
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
|
||||||
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
|
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
|
||||||
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
|
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
|
||||||
## 0.5.1 — 2026-01-01
|
## 0.5.1 — 2026-01-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ const files = [
|
|||||||
from: resolve(root, 'src/lib/query-ids.json'),
|
from: resolve(root, 'src/lib/query-ids.json'),
|
||||||
to: resolve(root, 'dist/lib/query-ids.json'),
|
to: resolve(root, 'dist/lib/query-ids.json'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
from: resolve(root, 'src/lib/features.json'),
|
||||||
|
to: resolve(root, 'dist/lib/features.json'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
mkdirSync(dirname(file.to), { recursive: true });
|
mkdirSync(dirname(file.to), { recursive: true });
|
||||||
writeFileSync(file.to, readFileSync(file.from));
|
writeFileSync(file.to, readFileSync(file.from));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import type { CliContext } from '../cli/shared.js';
|
import type { CliContext } from '../cli/shared.js';
|
||||||
|
import {
|
||||||
|
type FeatureOverrides,
|
||||||
|
getFeatureOverridesSnapshot,
|
||||||
|
refreshFeatureOverridesCache,
|
||||||
|
} from '../lib/runtime-features.js';
|
||||||
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
|
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
|
||||||
|
|
||||||
|
function countFeatureOverrides(overrides: FeatureOverrides): number {
|
||||||
|
let count = 0;
|
||||||
|
if (overrides.global) {
|
||||||
|
count += Object.keys(overrides.global).length;
|
||||||
|
}
|
||||||
|
if (overrides.sets) {
|
||||||
|
for (const setOverrides of Object.values(overrides.sets)) {
|
||||||
|
count += Object.keys(setOverrides).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerQueryIdsCommand(program: Command, ctx: CliContext): void {
|
export function registerQueryIdsCommand(program: Command, ctx: CliContext): void {
|
||||||
program
|
program
|
||||||
.command('query-ids')
|
.command('query-ids')
|
||||||
@@ -25,16 +43,31 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
|
|||||||
if (cmdOpts.fresh) {
|
if (cmdOpts.fresh) {
|
||||||
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
|
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
|
||||||
await runtimeQueryIds.refresh(operations, { force: true });
|
await runtimeQueryIds.refresh(operations, { force: true });
|
||||||
|
console.error(`${ctx.p('info')}Refreshing feature overrides…`);
|
||||||
|
await refreshFeatureOverridesCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const featureSnapshot = getFeatureOverridesSnapshot();
|
||||||
const info = await runtimeQueryIds.getSnapshotInfo();
|
const info = await runtimeQueryIds.getSnapshotInfo();
|
||||||
if (!info) {
|
if (!info) {
|
||||||
if (cmdOpts.json) {
|
if (cmdOpts.json) {
|
||||||
console.log(JSON.stringify({ cached: false, cachePath: runtimeQueryIds.cachePath }, null, 2));
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
cached: false,
|
||||||
|
cachePath: runtimeQueryIds.cachePath,
|
||||||
|
featuresPath: featureSnapshot.cachePath,
|
||||||
|
features: featureSnapshot.overrides,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
|
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
|
||||||
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
|
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
|
||||||
|
console.log(`features_path: ${featureSnapshot.cachePath}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +82,8 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
|
|||||||
ageMs: info.ageMs,
|
ageMs: info.ageMs,
|
||||||
ids: info.snapshot.ids,
|
ids: info.snapshot.ids,
|
||||||
discovery: info.snapshot.discovery,
|
discovery: info.snapshot.discovery,
|
||||||
|
featuresPath: featureSnapshot.cachePath,
|
||||||
|
features: featureSnapshot.overrides,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -62,5 +97,7 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
|
|||||||
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
|
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
|
||||||
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
|
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
|
||||||
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
|
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
|
||||||
|
console.log(`features_path: ${featureSnapshot.cachePath}`);
|
||||||
|
console.log(`features: ${countFeatureOverrides(featureSnapshot.overrides)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"global": {
|
||||||
|
"responsive_web_grok_annotations_enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
|
||||||
|
import defaultOverrides from './features.json' with { type: 'json' };
|
||||||
|
|
||||||
|
export type FeatureOverrides = {
|
||||||
|
global?: Record<string, boolean>;
|
||||||
|
sets?: Record<string, Record<string, boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizedFeatureOverrides = {
|
||||||
|
global: Record<string, boolean>;
|
||||||
|
sets: Record<string, Record<string, boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureOverridesSnapshot = {
|
||||||
|
cachePath: string;
|
||||||
|
overrides: FeatureOverrides;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CACHE_FILENAME = 'features.json';
|
||||||
|
let cachedOverrides: NormalizedFeatureOverrides | null = null;
|
||||||
|
|
||||||
|
function normalizeFeatureMap(value: unknown): Record<string, boolean> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (typeof entry === 'boolean') {
|
||||||
|
result[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOverrides(value: unknown): NormalizedFeatureOverrides {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return { global: {}, sets: {} };
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const global = normalizeFeatureMap(record.global);
|
||||||
|
const sets: Record<string, Record<string, boolean>> = {};
|
||||||
|
const rawSets =
|
||||||
|
record.sets && typeof record.sets === 'object' && !Array.isArray(record.sets)
|
||||||
|
? (record.sets as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
for (const [setName, setValue] of Object.entries(rawSets)) {
|
||||||
|
const normalized = normalizeFeatureMap(setValue);
|
||||||
|
if (Object.keys(normalized).length > 0) {
|
||||||
|
sets[setName] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { global, sets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOverrides(
|
||||||
|
base: NormalizedFeatureOverrides,
|
||||||
|
next: NormalizedFeatureOverrides,
|
||||||
|
): NormalizedFeatureOverrides {
|
||||||
|
const sets: Record<string, Record<string, boolean>> = { ...base.sets };
|
||||||
|
for (const [setName, overrides] of Object.entries(next.sets)) {
|
||||||
|
const existing = sets[setName];
|
||||||
|
sets[setName] = existing ? { ...existing, ...overrides } : { ...overrides };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
global: { ...base.global, ...next.global },
|
||||||
|
sets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFeatureOverrides(overrides: NormalizedFeatureOverrides): FeatureOverrides {
|
||||||
|
const result: FeatureOverrides = {};
|
||||||
|
if (Object.keys(overrides.global).length > 0) {
|
||||||
|
result.global = overrides.global;
|
||||||
|
}
|
||||||
|
const setEntries = Object.entries(overrides.sets).filter(([, value]) => Object.keys(value).length > 0);
|
||||||
|
if (setEntries.length > 0) {
|
||||||
|
result.sets = Object.fromEntries(setEntries);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFeaturesCachePath(): string {
|
||||||
|
const override = process.env.BIRD_FEATURES_CACHE ?? process.env.BIRD_FEATURES_PATH;
|
||||||
|
if (override && override.trim().length > 0) {
|
||||||
|
return path.resolve(override.trim());
|
||||||
|
}
|
||||||
|
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOverridesFromFile(cachePath: string): NormalizedFeatureOverrides | null {
|
||||||
|
if (!existsSync(cachePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(cachePath, 'utf8');
|
||||||
|
return normalizeOverrides(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOverridesFromEnv(): NormalizedFeatureOverrides | null {
|
||||||
|
const raw = process.env.BIRD_FEATURES_JSON;
|
||||||
|
if (!raw || raw.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizeOverrides(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOverridesToDisk(cachePath: string, overrides: NormalizedFeatureOverrides): Promise<void> {
|
||||||
|
const payload = toFeatureOverrides(overrides);
|
||||||
|
return mkdir(path.dirname(cachePath), { recursive: true }).then(() =>
|
||||||
|
writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFeatureOverrides(): NormalizedFeatureOverrides {
|
||||||
|
if (cachedOverrides) {
|
||||||
|
return cachedOverrides;
|
||||||
|
}
|
||||||
|
const base = normalizeOverrides(defaultOverrides);
|
||||||
|
const fromFile = readOverridesFromFile(resolveFeaturesCachePath());
|
||||||
|
const fromEnv = readOverridesFromEnv();
|
||||||
|
let merged = base;
|
||||||
|
if (fromFile) {
|
||||||
|
merged = mergeOverrides(merged, fromFile);
|
||||||
|
}
|
||||||
|
if (fromEnv) {
|
||||||
|
merged = mergeOverrides(merged, fromEnv);
|
||||||
|
}
|
||||||
|
cachedOverrides = merged;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureOverridesSnapshot(): FeatureOverridesSnapshot {
|
||||||
|
const overrides = toFeatureOverrides(loadFeatureOverrides());
|
||||||
|
return {
|
||||||
|
cachePath: resolveFeaturesCachePath(),
|
||||||
|
overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFeatureOverrides(setName: string, base: Record<string, boolean>): Record<string, boolean> {
|
||||||
|
const overrides = loadFeatureOverrides();
|
||||||
|
const globalOverrides = overrides.global;
|
||||||
|
const setOverrides = overrides.sets[setName];
|
||||||
|
if (Object.keys(globalOverrides).length === 0 && (!setOverrides || Object.keys(setOverrides).length === 0)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
if (setOverrides) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...globalOverrides,
|
||||||
|
...setOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...globalOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshFeatureOverridesCache(): Promise<FeatureOverridesSnapshot> {
|
||||||
|
const cachePath = resolveFeaturesCachePath();
|
||||||
|
const base = normalizeOverrides(defaultOverrides);
|
||||||
|
const fromFile = readOverridesFromFile(cachePath);
|
||||||
|
const merged = mergeOverrides(base, fromFile ?? { global: {}, sets: {} });
|
||||||
|
await writeOverridesToDisk(cachePath, merged);
|
||||||
|
cachedOverrides = null;
|
||||||
|
return { cachePath, overrides: toFeatureOverrides(merged) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFeatureOverridesCache(): void {
|
||||||
|
cachedOverrides = null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { applyFeatureOverrides } from './runtime-features.js';
|
||||||
|
|
||||||
export function buildArticleFeatures(): Record<string, boolean> {
|
export function buildArticleFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('article', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||||
responsive_web_profile_redirect_enabled: true,
|
responsive_web_profile_redirect_enabled: true,
|
||||||
@@ -35,11 +37,11 @@ export function buildArticleFeatures(): Record<string, boolean> {
|
|||||||
responsive_web_grok_imagine_annotation_enabled: true,
|
responsive_web_grok_imagine_annotation_enabled: true,
|
||||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTweetDetailFeatures(): Record<string, boolean> {
|
export function buildTweetDetailFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('tweetDetail', {
|
||||||
...buildArticleFeatures(),
|
...buildArticleFeatures(),
|
||||||
responsive_web_graphql_exclude_directive_enabled: true,
|
responsive_web_graphql_exclude_directive_enabled: true,
|
||||||
communities_web_enable_tweet_community_results_fetch: true,
|
communities_web_enable_tweet_community_results_fetch: true,
|
||||||
@@ -52,7 +54,7 @@ export function buildTweetDetailFeatures(): Record<string, boolean> {
|
|||||||
tweet_awards_web_tipping_enabled: false,
|
tweet_awards_web_tipping_enabled: false,
|
||||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||||
verified_phone_label_enabled: false,
|
verified_phone_label_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildArticleFieldToggles(): Record<string, boolean> {
|
export function buildArticleFieldToggles(): Record<string, boolean> {
|
||||||
@@ -67,7 +69,7 @@ export function buildArticleFieldToggles(): Record<string, boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildSearchFeatures(): Record<string, boolean> {
|
export function buildSearchFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('search', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||||
responsive_web_profile_redirect_enabled: true,
|
responsive_web_profile_redirect_enabled: true,
|
||||||
@@ -105,11 +107,11 @@ export function buildSearchFeatures(): Record<string, boolean> {
|
|||||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||||
articles_preview_enabled: true,
|
articles_preview_enabled: true,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTweetCreateFeatures(): Record<string, boolean> {
|
export function buildTweetCreateFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('tweetCreate', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||||
premium_content_api_read_enabled: false,
|
premium_content_api_read_enabled: false,
|
||||||
@@ -145,11 +147,11 @@ export function buildTweetCreateFeatures(): Record<string, boolean> {
|
|||||||
responsive_web_grok_imagine_annotation_enabled: true,
|
responsive_web_grok_imagine_annotation_enabled: true,
|
||||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTimelineFeatures(): Record<string, boolean> {
|
export function buildTimelineFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('timeline', {
|
||||||
...buildSearchFeatures(),
|
...buildSearchFeatures(),
|
||||||
blue_business_profile_image_shape_enabled: true,
|
blue_business_profile_image_shape_enabled: true,
|
||||||
responsive_web_text_conversations_enabled: false,
|
responsive_web_text_conversations_enabled: false,
|
||||||
@@ -159,22 +161,22 @@ export function buildTimelineFeatures(): Record<string, boolean> {
|
|||||||
interactive_text_enabled: true,
|
interactive_text_enabled: true,
|
||||||
longform_notetweets_richtext_consumption_enabled: true,
|
longform_notetweets_richtext_consumption_enabled: true,
|
||||||
responsive_web_media_download_video_enabled: false,
|
responsive_web_media_download_video_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBookmarksFeatures(): Record<string, boolean> {
|
export function buildBookmarksFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('bookmarks', {
|
||||||
...buildTimelineFeatures(),
|
...buildTimelineFeatures(),
|
||||||
graphql_timeline_v2_bookmark_timeline: true,
|
graphql_timeline_v2_bookmark_timeline: true,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLikesFeatures(): Record<string, boolean> {
|
export function buildLikesFeatures(): Record<string, boolean> {
|
||||||
return buildTimelineFeatures();
|
return applyFeatureOverrides('likes', buildTimelineFeatures());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildListsFeatures(): Record<string, boolean> {
|
export function buildListsFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('lists', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||||
responsive_web_profile_redirect_enabled: true,
|
responsive_web_profile_redirect_enabled: true,
|
||||||
@@ -210,11 +212,11 @@ export function buildListsFeatures(): Record<string, boolean> {
|
|||||||
responsive_web_grok_imagine_annotation_enabled: true,
|
responsive_web_grok_imagine_annotation_enabled: true,
|
||||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFollowingFeatures(): Record<string, boolean> {
|
export function buildFollowingFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('following', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: false,
|
profile_label_improvements_pcf_label_in_post_enabled: false,
|
||||||
responsive_web_profile_redirect_enabled: true,
|
responsive_web_profile_redirect_enabled: true,
|
||||||
@@ -250,5 +252,5 @@ export function buildFollowingFeatures(): Record<string, boolean> {
|
|||||||
responsive_web_grok_imagine_annotation_enabled: false,
|
responsive_web_grok_imagine_annotation_enabled: false,
|
||||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { clearFeatureOverridesCache, refreshFeatureOverridesCache } from '../src/lib/runtime-features.js';
|
||||||
|
import { buildLikesFeatures, buildSearchFeatures } from '../src/lib/twitter-client-features.js';
|
||||||
|
|
||||||
|
describe('runtime-features', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.BIRD_FEATURES_JSON;
|
||||||
|
delete process.env.BIRD_FEATURES_CACHE;
|
||||||
|
delete process.env.BIRD_FEATURES_PATH;
|
||||||
|
clearFeatureOverridesCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies global and set overrides from env json', () => {
|
||||||
|
process.env.BIRD_FEATURES_JSON = JSON.stringify({
|
||||||
|
global: {
|
||||||
|
global_flag: true,
|
||||||
|
ignored: 'nope',
|
||||||
|
},
|
||||||
|
sets: {
|
||||||
|
search: {
|
||||||
|
global_flag: false,
|
||||||
|
search_only: true,
|
||||||
|
bad: 1,
|
||||||
|
},
|
||||||
|
likes: {
|
||||||
|
likes_only: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearFeatureOverridesCache();
|
||||||
|
const search = buildSearchFeatures();
|
||||||
|
expect(search.global_flag).toBe(false);
|
||||||
|
expect(search.search_only).toBe(true);
|
||||||
|
expect(search.bad).toBeUndefined();
|
||||||
|
|
||||||
|
const likes = buildLikesFeatures();
|
||||||
|
expect(likes.global_flag).toBe(true);
|
||||||
|
expect(likes.likes_only).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refresh merges defaults with existing cache', async () => {
|
||||||
|
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
const cachePath = path.join(cacheDir, 'features.json');
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
cachePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
existing_flag: true,
|
||||||
|
},
|
||||||
|
sets: {
|
||||||
|
search: {
|
||||||
|
existing_search: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.BIRD_FEATURES_PATH = cachePath;
|
||||||
|
await refreshFeatureOverridesCache();
|
||||||
|
|
||||||
|
const raw = await readFile(cachePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
global?: Record<string, boolean>;
|
||||||
|
sets?: Record<string, Record<string, boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.global?.existing_flag).toBe(true);
|
||||||
|
expect(parsed.global?.responsive_web_grok_annotations_enabled).toBe(false);
|
||||||
|
expect(parsed.sets?.search?.existing_search).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies overrides from cache file', async () => {
|
||||||
|
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
const cachePath = path.join(cacheDir, 'features.json');
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
cachePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
file_flag: true,
|
||||||
|
},
|
||||||
|
sets: {
|
||||||
|
search: {
|
||||||
|
file_search: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.BIRD_FEATURES_PATH = cachePath;
|
||||||
|
clearFeatureOverridesCache();
|
||||||
|
|
||||||
|
const search = buildSearchFeatures();
|
||||||
|
expect(search.file_flag).toBe(true);
|
||||||
|
expect(search.file_search).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user