Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/mobile/src/components/app-unlock-screen.test-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ vi.mock('@/lib/hooks/use-trusted-hosts', () => ({
useTrustedHosts: () => ({ trustedHosts: [], hasLoaded: true }),
}));
vi.mock('@/lib/picker-bridge', () => ({ setLanguagePickerBridge: vi.fn() }));
// The preferences screen mounts the feature-flag debug surface, which reads
// PostHog flag statuses; the real module pulls in expo-application's native
// chain, which no mounted test loads. An empty registry keeps the section
// out of these scenes.
vi.mock('@/lib/analytics/posthog', () => ({
useFeatureFlagStatuses: () => [],
}));

function Draft() {
const [value, onChange] = useState('saved draft');
Expand Down
124 changes: 124 additions & 0 deletions apps/mobile/src/components/feature-flags-section.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as preferences-screen.mounted.test.tsx) */
import { createElement } from 'react';
import { act, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import '@/i18n';
import { FeatureFlagsSection } from '@/components/feature-flags-section';
import { renderWithProviders } from '@/test/render-with-providers';

/** Statuses the mocked PostHog module reports; each test seeds this. */
const posthog = vi.hoisted(() => ({
statuses: [] as Record<string, unknown>[],
}));
vi.mock('@/lib/analytics/posthog', () => ({
useFeatureFlagStatuses: () => posthog.statuses,
}));

vi.mock('react-native', () => ({
View: 'View',
}));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

let view: Awaited<ReturnType<typeof renderWithProviders>> | undefined = undefined;
async function flush(): Promise<void> {
await act(async () => {
await vi.dynamicImportSettled();
});
}
async function mount(): Promise<ReactTestRenderer> {
view = await renderWithProviders(createElement(FeatureFlagsSection));
await flush();
return view.renderer;
}

/** All rendered Text strings, flattened (composed lines arrive as arrays). */
function textLines(tree: ReactTestRenderer): string[] {
return tree.root
.findAll(node => typeof node.type === 'string' && (node.type as string) === 'Text')
.map(node => [node.props.children].flat().join(''));
}

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
posthog.statuses = [];
});
afterEach(() => {
view?.unmount();
view = undefined;
vi.unstubAllGlobals();
});

const applied = {
key: 'mobile-pr-review',
minAppVersion: '1.0.4',
defaultValue: true,
appVersion: '1.0.5',
applied: true,
value: true,
reason: 'applied',
loaded: true,
};
const skipped = {
key: 'mobile-quick-chat',
minAppVersion: '1.0.6',
defaultValue: false,
appVersion: '1.0.5',
applied: false,
value: false,
reason: 'build-too-old',
loaded: true,
};
const unloaded = {
key: 'mobile-pr-review',
minAppVersion: '1.0.4',
defaultValue: true,
appVersion: '1.0.8',
applied: false,
value: true,
reason: 'applied',
loaded: false,
};

describe('FeatureFlagsSection', () => {
it('lists every flag with the value the build acts on and why', async () => {
posthog.statuses = [applied, skipped];
const tree = await mount();

const lines = textLines(tree);
expect(lines).toContain('Feature flags');
expect(lines).toContain('mobile-pr-review');
expect(lines).toContain('Enabled · remote · ≥ 1.0.4');
expect(lines).toContain('mobile-quick-chat');
expect(lines).toContain('Off · default · < 1.0.6');
expect(lines).toContain('v1.0.5');
});

it('marks a flag the build skips as default in use with the minimum version', async () => {
posthog.statuses = [skipped];
const tree = await mount();

expect(textLines(tree)).toContain('Off · default · < 1.0.6');
});

it('marks an applied flag as remote value in use', async () => {
posthog.statuses = [applied];
const tree = await mount();

expect(textLines(tree)).toContain('Enabled · remote · ≥ 1.0.4');
});

it('marks defaults in use while remote flags are not loaded yet', async () => {
posthog.statuses = [unloaded];
const tree = await mount();

expect(textLines(tree)).toContain('Enabled · default · not loaded');
});

it('renders nothing when the registry is empty', async () => {
posthog.statuses = [];
const tree = await mount();

expect(textLines(tree)).toEqual([]);
});
});
61 changes: 61 additions & 0 deletions apps/mobile/src/components/feature-flags-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { Text } from '@/components/ui/text';
import { type FeatureFlagStatus, useFeatureFlagStatuses } from '@/lib/analytics/posthog';

/**
* Debug surface for feature flags (Preferences). Lists every registered flag
* with what this build resolved it to and why, so a tester can see which
* flags the build applies and which it skips because the build predates the
* flag's minimum app version. Read-only: flags are controlled in PostHog.
*
* Each row reads `<value> · <source> · <version relation>`, e.g.
* `Enabled · remote · ≥ 1.0.4`: the value the UI acts on, whether it came
* from PostHog or the flag's default, and the gate that decided. The source
* and relation copy is technical notation (see the i18n allowlist); the
* translated part is the value word and the section header.
*/
function FlagRow({ status }: { status: FeatureFlagStatus }) {
const { t } = useTranslation();
const value = status.value ? t('common.enabled') : t('common.off');
let reason = t('preferences.featureFlagNotLoaded');
if (status.loaded) {
reason = status.applied
? t('preferences.featureFlagApplied', { min: status.minAppVersion })
: t('preferences.featureFlagSkipped', { min: status.minAppVersion });
}
return (
<View className="rounded-lg bg-secondary px-3 py-3">
<Text className="text-sm font-medium">{status.key}</Text>
<Text variant="muted" className="mt-0.5 text-xs">
{value} · {reason}
</Text>
</View>
);
}

export function FeatureFlagsSection() {
const { t } = useTranslation();
const statuses = useFeatureFlagStatuses();
if (statuses.length === 0) {
return null;
}
return (
<View className="mt-3 gap-3">
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
{t('preferences.featureFlags')}
</Text>
<View className="gap-3">
{statuses.map(status => (
<FlagRow key={status.key} status={status} />
))}
</View>
<Text variant="muted" className="text-xs">
{t('preferences.featureFlagsBuild', {
version: statuses[0]?.appVersion ?? '?',
})}
</Text>
</View>
);
}
49 changes: 49 additions & 0 deletions apps/mobile/src/components/preferences-screen.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as image-viewer-modal.mounted.test.tsx) */
/* oxlint-disable max-lines -- this screen's mounted suite grew past 300 lines with the feature-flag debug surface cases */
import { type ElementType } from 'react';
import { act, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -10,6 +11,15 @@ import { renderWithProviders } from '@/test/render-with-providers';

const push = vi.hoisted(() => vi.fn());
const setLanguagePickerBridge = vi.hoisted(() => vi.fn());
// The screen mounts the feature-flag debug surface, which reads PostHog flag
// statuses; seed an empty registry so the section stays out of these tests'
// snapshots unless a case opts in.
const posthog = vi.hoisted(() => ({
statuses: [] as Record<string, unknown>[],
}));
vi.mock('@/lib/analytics/posthog', () => ({
useFeatureFlagStatuses: () => posthog.statuses,
}));
const native = vi.hoisted(() => ({
hasHardwareAsync: vi.fn(),
isEnrolledAsync: vi.fn(),
Expand Down Expand Up @@ -135,6 +145,7 @@ async function mountPreferences(raw: string | null = null): Promise<ReactTestRen
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.resetAllMocks();
posthog.statuses = [];
storage.setItemAsync.mockImplementation(async (_key: string, value: string) => {
await Promise.resolve();
storage.value = value;
Expand Down Expand Up @@ -216,6 +227,44 @@ describe('PreferencesScreen Return-sends switch', () => {
});
});

describe('PreferencesScreen feature-flag debug surface', () => {
it('lists which flags the build applies and which it skips, with reasons', async () => {
posthog.statuses = [
{
key: 'mobile-pr-review',
minAppVersion: '1.0.4',
defaultValue: true,
appVersion: '1.0.5',
applied: true,
value: true,
reason: 'applied',
loaded: true,
},
{
key: 'mobile-quick-chat',
minAppVersion: '1.0.6',
defaultValue: false,
appVersion: '1.0.5',
applied: false,
value: false,
reason: 'build-too-old',
loaded: true,
},
];
const renderer = await mountPreferences();

const lines = renderer.root
.findAll(node => typeof node.type === 'string' && (node.type as string) === 'Text')
.map(node => [node.props.children].flat().join(''));
expect(lines).toContain('Feature flags');
expect(lines).toContain('mobile-pr-review');
expect(lines).toContain('Enabled · remote · ≥ 1.0.4');
expect(lines).toContain('mobile-quick-chat');
expect(lines).toContain('Off · default · < 1.0.6');
expect(lines).toContain('v1.0.5');
});
});

function biometric(renderer: ReactTestRenderer) {
return renderer.root.findByProps({ accessibilityLabel: 'Unlock with biometrics' });
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/components/preferences-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ActivityIndicator, Switch, View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { AppUnlockFeedback } from '@/components/app-unlock-screen';
import { FeatureFlagsSection } from '@/components/feature-flags-section';
import { ScreenHeader } from '@/components/screen-header';
import { TabScreenScrollView } from '@/components/tab-screen';
import { ConfigureRow } from '@/components/ui/configure-row';
Expand Down Expand Up @@ -188,6 +189,9 @@ export function PreferencesScreen() {
/>
</View>

{/* Feature flags — debug surface: which flags this build applies and why. */}
<FeatureFlagsSection />

{/* Account */}
<View className="mt-3 gap-3">
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/az.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/be.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/bg.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion apps/mobile/src/i18n/locales/bn.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading