mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(whats-new): gate the card to release channels so it can't intrude on CI
The R2 rearchitecture dropped the daemon's channel gate, so development/CI
builds fetched the live document and showed the card on Home. That broke the
P0 "settings dialog reachable" smoke (getByRole('dialog') matched both the
settings modal and the what's-new card, which also carries role="dialog") and
would have dirtied every Home visual baseline. Restore the gate: only
beta/prerelease/preview/stable fetch the document; other channels resolve to no
card and never touch the network. OD_WHATS_NEW_URL still opts any channel in
for e2e fixtures that exercise the card on purpose.
This commit is contained in:
@@ -12,7 +12,7 @@ export function registerWhatsNewRoutes(app: Express, ctx: RegisterWhatsNewRoutes
|
||||
|
||||
app.get('/api/whats-new', async (_req, res) => {
|
||||
const versionInfo = await readCurrentAppVersionInfo();
|
||||
const result = await whatsNew.readWhatsNew();
|
||||
const result = await whatsNew.readWhatsNew(versionInfo.channel);
|
||||
const payload: WhatsNewResponse = {
|
||||
version: versionInfo.version,
|
||||
id: result.id,
|
||||
|
||||
@@ -23,12 +23,20 @@ export interface WhatsNewServiceOptions {
|
||||
}
|
||||
|
||||
export interface WhatsNewService {
|
||||
readWhatsNew(): Promise<WhatsNewReadResult>;
|
||||
readWhatsNew(channel: string): Promise<WhatsNewReadResult>;
|
||||
}
|
||||
|
||||
/** The dedicated, hardcoded highlights document. Operators update this file. */
|
||||
export const DEFAULT_WHATS_NEW_URL = 'https://whatsnew.open-design.ai/whats-new.json';
|
||||
|
||||
/**
|
||||
* The post-update card is a release feature. Only real release channels fetch
|
||||
* the hosted document; development/CI builds resolve to no card so the card
|
||||
* never intrudes on tests or unreleased builds. `OD_WHATS_NEW_URL` opts any
|
||||
* channel in (used by e2e fixtures that exercise the card on purpose).
|
||||
*/
|
||||
const WHATS_NEW_RELEASE_CHANNELS = new Set(['beta', 'prerelease', 'preview', 'stable']);
|
||||
|
||||
// Short enough that an operator's edit reaches users on their next Home visit
|
||||
// without a long stale window, long enough that Home activations do not hammer
|
||||
// the origin.
|
||||
@@ -36,11 +44,15 @@ const WHATS_NEW_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const WHATS_NEW_TIMEOUT_MS = 4_000;
|
||||
|
||||
/**
|
||||
* The document URL. `OD_WHATS_NEW_URL` overrides it for local fixtures and
|
||||
* tests (e.g. a tools-serve endpoint), otherwise the dedicated R2 object.
|
||||
* The document URL, or null when this build must not show the card.
|
||||
* `OD_WHATS_NEW_URL` overrides it for local fixtures and tests (e.g. a
|
||||
* tools-serve endpoint) regardless of channel; otherwise the dedicated R2
|
||||
* object is used only on release channels.
|
||||
*/
|
||||
export function whatsNewSourceUrl(env: NodeJS.ProcessEnv): string {
|
||||
return env.OD_WHATS_NEW_URL?.trim() || DEFAULT_WHATS_NEW_URL;
|
||||
export function whatsNewSourceUrl(env: NodeJS.ProcessEnv, channel: string): string | null {
|
||||
const override = env.OD_WHATS_NEW_URL?.trim();
|
||||
if (override) return override;
|
||||
return WHATS_NEW_RELEASE_CHANNELS.has(channel) ? DEFAULT_WHATS_NEW_URL : null;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
@@ -114,8 +126,13 @@ export function createWhatsNewService({
|
||||
let cache: { key: string; result: WhatsNewReadResult } | null = null;
|
||||
let inflight: Promise<WhatsNewReadResult> | null = null;
|
||||
|
||||
async function readWhatsNew(): Promise<WhatsNewReadResult> {
|
||||
const sourceUrl = whatsNewSourceUrl(env);
|
||||
async function readWhatsNew(channel: string): Promise<WhatsNewReadResult> {
|
||||
const sourceUrl = whatsNewSourceUrl(env, channel);
|
||||
if (sourceUrl == null) {
|
||||
// Development/CI builds (no release channel, no override) never show the
|
||||
// card and never reach out to the network.
|
||||
return { id: null, content: null, fetchedAt: now(), stale: false };
|
||||
}
|
||||
const cacheKey = sourceUrl;
|
||||
const currentTime = now();
|
||||
if (
|
||||
|
||||
@@ -24,12 +24,18 @@ function jsonResponse(payload: unknown, status = 200): Response {
|
||||
}
|
||||
|
||||
describe('whatsNewSourceUrl', () => {
|
||||
it('defaults to the dedicated hosted document', () => {
|
||||
expect(whatsNewSourceUrl({})).toBe(DEFAULT_WHATS_NEW_URL);
|
||||
it('uses the dedicated hosted document on release channels', () => {
|
||||
expect(whatsNewSourceUrl({}, 'stable')).toBe(DEFAULT_WHATS_NEW_URL);
|
||||
expect(whatsNewSourceUrl({}, 'beta')).toBe(DEFAULT_WHATS_NEW_URL);
|
||||
});
|
||||
|
||||
it('honors OD_WHATS_NEW_URL for fixtures and tests', () => {
|
||||
expect(whatsNewSourceUrl({ OD_WHATS_NEW_URL: 'https://fixture.local/whats-new.json' })).toBe(
|
||||
it('resolves to null on non-release channels (development/CI show no card)', () => {
|
||||
expect(whatsNewSourceUrl({}, 'development')).toBeNull();
|
||||
expect(whatsNewSourceUrl({}, '')).toBeNull();
|
||||
});
|
||||
|
||||
it('honors OD_WHATS_NEW_URL for fixtures and tests regardless of channel', () => {
|
||||
expect(whatsNewSourceUrl({ OD_WHATS_NEW_URL: 'https://fixture.local/whats-new.json' }, 'development')).toBe(
|
||||
'https://fixture.local/whats-new.json',
|
||||
);
|
||||
});
|
||||
@@ -73,14 +79,27 @@ describe('createWhatsNewService', () => {
|
||||
return jsonResponse(DOC);
|
||||
},
|
||||
});
|
||||
const first = await service.readWhatsNew();
|
||||
const second = await service.readWhatsNew();
|
||||
const first = await service.readWhatsNew('stable');
|
||||
const second = await service.readWhatsNew('stable');
|
||||
expect(first.id).toBe('0.13.0');
|
||||
expect(first.content?.title).toBe('Design system sync');
|
||||
expect(second.content?.title).toBe('Design system sync');
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('skips the network entirely on non-release channels', async () => {
|
||||
const service = createWhatsNewService({
|
||||
env: {},
|
||||
fetchImpl: async () => {
|
||||
throw new Error('must not fetch');
|
||||
},
|
||||
});
|
||||
const result = await service.readWhatsNew('development');
|
||||
expect(result.id).toBeNull();
|
||||
expect(result.content).toBeNull();
|
||||
expect(result.stale).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves to null content instead of failing when the document is unreachable', async () => {
|
||||
const service = createWhatsNewService({
|
||||
env: {},
|
||||
@@ -88,7 +107,7 @@ describe('createWhatsNewService', () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
});
|
||||
const result = await service.readWhatsNew();
|
||||
const result = await service.readWhatsNew('stable');
|
||||
expect(result.id).toBeNull();
|
||||
expect(result.content).toBeNull();
|
||||
expect(result.stale).toBe(true);
|
||||
@@ -99,7 +118,7 @@ describe('createWhatsNewService', () => {
|
||||
env: {},
|
||||
fetchImpl: async () => jsonResponse({ error: 'nope' }, 404),
|
||||
});
|
||||
const result = await service.readWhatsNew();
|
||||
const result = await service.readWhatsNew('stable');
|
||||
expect(result.content).toBeNull();
|
||||
expect(result.stale).toBe(true);
|
||||
});
|
||||
|
||||
+6
-1
@@ -19,8 +19,13 @@ in release `metadata.json`. To change what users see, edit that one file.
|
||||
|
||||
- The daemon proxies it at `GET /api/whats-new` (also `od whats-new [--json]`),
|
||||
so the web UI and CLI read the exact same payload.
|
||||
- The card is a **release feature**: the daemon only fetches the document on
|
||||
real release channels (`beta`, `prerelease`, `preview`, `stable`). Development
|
||||
and CI builds resolve to no card and never hit the network, so the card never
|
||||
intrudes on tests or unreleased builds.
|
||||
- `OD_WHATS_NEW_URL` overrides the source for local development and tests (for
|
||||
example a `tools-serve` fixture endpoint).
|
||||
example a `tools-serve` fixture endpoint), and opts any channel in — set it to
|
||||
preview the card on a dev build.
|
||||
|
||||
## Show-once behavior
|
||||
|
||||
|
||||
Reference in New Issue
Block a user