mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
chore(ui): hide Mesh, Fleet Secrets, and Host Console behind experimental discovery (#1624)
Gate Routing, Secrets, Host Console, and Mesh dashboard/settings surfaces on the existing useExperimental readiness flag so immature operator surfaces stay out of the default UI while paid and admin backend gates remain unchanged.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
useExperimental,
|
||||
__resetExperimentalCacheForTests,
|
||||
} from '../useExperimental';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}));
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function statusJson(status: number, payload: unknown = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetExperimentalCacheForTests();
|
||||
apiFetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useExperimental', () => {
|
||||
it('stays unready then settles to true when /meta eventually returns experimental', async () => {
|
||||
let resolve!: (value: Response) => void;
|
||||
apiFetchMock.mockReturnValue(new Promise<Response>((r) => { resolve = r; }));
|
||||
|
||||
const { result } = renderHook(() => useExperimental());
|
||||
expect(result.current.experimentalReady).toBe(false);
|
||||
expect(result.current.experimental).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
resolve(okJson({ experimental: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.experimentalReady).toBe(true));
|
||||
expect(result.current.experimental).toBe(true);
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/meta', expect.objectContaining({ localOnly: true }));
|
||||
});
|
||||
|
||||
it('settles fail-closed on non-OK /meta', async () => {
|
||||
apiFetchMock.mockResolvedValue(statusJson(500, { error: 'boom' }));
|
||||
const { result } = renderHook(() => useExperimental());
|
||||
await waitFor(() => expect(result.current.experimentalReady).toBe(true));
|
||||
expect(result.current.experimental).toBe(false);
|
||||
});
|
||||
|
||||
it('settles false for malformed payloads that omit experimental true', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ experimental: 'yes' }));
|
||||
const { result } = renderHook(() => useExperimental());
|
||||
await waitFor(() => expect(result.current.experimentalReady).toBe(true));
|
||||
expect(result.current.experimental).toBe(false);
|
||||
});
|
||||
|
||||
it('settles fail-closed when the request throws', async () => {
|
||||
apiFetchMock.mockRejectedValue(new Error('network'));
|
||||
const { result } = renderHook(() => useExperimental());
|
||||
await waitFor(() => expect(result.current.experimentalReady).toBe(true));
|
||||
expect(result.current.experimental).toBe(false);
|
||||
});
|
||||
|
||||
it('dedupes concurrent callers onto one /meta fetch and shares the cache', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ experimental: true }));
|
||||
const a = renderHook(() => useExperimental());
|
||||
const b = renderHook(() => useExperimental());
|
||||
await waitFor(() => expect(a.result.current.experimentalReady).toBe(true));
|
||||
await waitFor(() => expect(b.result.current.experimentalReady).toBe(true));
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(a.result.current.experimental).toBe(true);
|
||||
expect(b.result.current.experimental).toBe(true);
|
||||
|
||||
const c = renderHook(() => useExperimental());
|
||||
expect(c.result.current.experimentalReady).toBe(true);
|
||||
expect(c.result.current.experimental).toBe(true);
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
export interface ExperimentalDiscovery {
|
||||
/** True when SENCHO_EXPERIMENTAL === 'true' on the gateway. */
|
||||
experimental: boolean;
|
||||
/** True once /meta has settled (success or fail-closed). */
|
||||
experimentalReady: boolean;
|
||||
}
|
||||
|
||||
// Module-scope cache: read once at boot, do not invalidate. The
|
||||
// SENCHO_EXPERIMENTAL flag is read from the gateway node's process
|
||||
// env at request time, so it cannot flip mid-session without a
|
||||
// restart. If the initial fetch fails the value sticks at false until
|
||||
// a full reload; that is acceptable for a dev-only flag.
|
||||
// a full reload; that is acceptable for a discovery-only flag.
|
||||
let cached: boolean | null = null;
|
||||
let inflight: Promise<boolean> | null = null;
|
||||
|
||||
@@ -37,16 +44,28 @@ async function fetchExperimental(): Promise<boolean> {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function useExperimental(): boolean {
|
||||
const [value, setValue] = useState<boolean>(cached ?? false);
|
||||
/** Test-only: reset the module cache between vitest cases. */
|
||||
export function __resetExperimentalCacheForTests(): void {
|
||||
cached = null;
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
export function useExperimental(): ExperimentalDiscovery {
|
||||
const [state, setState] = useState<ExperimentalDiscovery>(() =>
|
||||
cached !== null
|
||||
? { experimental: cached, experimentalReady: true }
|
||||
: { experimental: false, experimentalReady: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchExperimental().then((next) => {
|
||||
if (active) setValue(next);
|
||||
if (active) setState({ experimental: next, experimentalReady: true });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
return value;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user