import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import semver from 'semver'; import { RETIRED_NOTICE_IDS, SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js'; import { isNoticeVersionActive } from '../../../src/systemNotices/service.js'; /** Collect all actionIds registered via registerNoticeAction() in client source files. */ function collectRegisteredActionIds(): Set { const clientSrc = path.resolve(__dirname, '../../../../client/src'); const ids = new Set(); const queue = [clientSrc]; while (queue.length) { const dir = queue.pop()!; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { queue.push(full); continue; } if (!entry.name.endsWith('noticeActions.ts') && !entry.name.endsWith('noticeActions.js')) continue; const src = fs.readFileSync(full, 'utf8'); for (const m of src.matchAll(/registerNoticeAction\(\s*['"]([^'"]+)['"]/g)) { ids.add(m[1]); } } } return ids; } describe('registry integrity', () => { it('has no duplicate ids', () => { const ids = SYSTEM_NOTICES.map(n => n.id); expect(new Set(ids).size).toBe(ids.length); }); it('all action CTAs reference a registered actionId', () => { const registeredActionIds = collectRegisteredActionIds(); const actionCtaIds = SYSTEM_NOTICES .filter(n => n.cta?.kind === 'action') .map(n => (n.cta as { actionId: string }).actionId); for (const id of actionCtaIds) { expect(registeredActionIds, `actionId "${id}" not found in any client noticeActions.ts`).toContain(id); } }); it('all publishedAt are valid ISO dates', () => { for (const n of SYSTEM_NOTICES) { expect(() => new Date(n.publishedAt).toISOString()).not.toThrow(); } }); it('minVersion and maxVersion are valid semver when set, and minVersion <= maxVersion when both set', () => { for (const n of SYSTEM_NOTICES) { if (n.minVersion !== undefined) { expect(semver.valid(n.minVersion), `notice "${n.id}" has invalid minVersion "${n.minVersion}"`).not.toBeNull(); } if (n.maxVersion !== undefined) { expect(semver.valid(n.maxVersion), `notice "${n.id}" has invalid maxVersion "${n.maxVersion}"`).not.toBeNull(); } if (n.minVersion && n.maxVersion) { expect( semver.lte(n.minVersion, n.maxVersion), `notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}` ).toBe(true); } } }); it('the release notes come back on every upgrade, with no upper bound', () => { const release = SYSTEM_NOTICES.find(n => n.id === 'release-notes'); expect(release).toBeDefined(); // Nothing on 3.x ships this copy, and the thank-you notice still covers it there. expect(isNoticeVersionActive(release!, '3.4.1')).toBe(false); // From there on every version carries it, a patch release included, so no // install is left with nothing to show because nobody wrote a new window. for (const version of ['4.0.0', '4.2.1', '4.3.0', '4.3.1', '4.9.9', '5.0.0', '12.4.0']) { expect(isNoticeVersionActive(release!, version), version).toBe(true); } expect(release!.maxVersion).toBeUndefined(); // The window alone is not enough: a one-time dismissal would retire it for good // the first time somebody closed it. expect(release!.recurring).toBe('per-version'); }); it('the 4.0.0 release notice is retired and its id stays reserved', () => { expect(SYSTEM_NOTICES.some(n => n.id === 'release-4-0-0')).toBe(false); expect(RETIRED_NOTICE_IDS).toContain('release-4-0-0'); for (const id of RETIRED_NOTICE_IDS) { expect(SYSTEM_NOTICES.some(n => n.id === id), id).toBe(false); } }); it('the thank-you notice hands over to the release modal at 4.0.0', () => { const thankYou = SYSTEM_NOTICES.find(n => n.id === 'thank-you-support'); expect(thankYou).toBeDefined(); // Both carry the same thank-you and the same two support links, so exactly // one of them may be active at any version. expect(isNoticeVersionActive(thankYou!, '3.4.1')).toBe(true); expect(isNoticeVersionActive(thankYou!, '4.0.0')).toBe(false); const release = SYSTEM_NOTICES.find(n => n.id === 'release-notes')!; for (const version of ['3.4.1', '4.0.0', '4.0.7', '4.1.0', '4.3.0', '5.0.0']) { const active = [thankYou!, release].filter(n => isNoticeVersionActive(n, version)); expect(active.length, `thank-you and release notes at ${version}`).toBe(1); } }); });