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:
lefarcen
2026-07-10 15:29:48 +08:00
parent 7397c1bb89
commit 5f17873e02
4 changed files with 58 additions and 17 deletions
+1 -1
View File
@@ -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,
+24 -7
View File
@@ -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 (
+27 -8
View File
@@ -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
View File
@@ -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