Files
Codeman/test/security-notices.test.ts
T
Codeman maintainer 668a220abb test(ui): pin security notice selection, placement and wiring
The pure select() and plan() loaded from the shipped constants.js, the
shipped JSON itself (unique ids, https links, real dates, every cli a
stock registry id, a zh-CN title wherever there is an English one), and
the wiring in a JSDOM page: fresh install and same-version reload get the
card, an existing install's update toast carries the notice, the update
toast without a notice matches master's DOM byte for byte, seen only on a
user action, storage blocked shows nothing, base path, zh-CN, and the
card's CSS contract (theme tokens, motion, phone width).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:59:39 +02:00

873 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Security notices: a designed warning card per notice in the HAND-maintained
* src/web/public/security-notices.json (the first: OpenCode's Console breach, October 2026),
* or a warning section INSIDE the once-per-release update toast when that toast shows.
*
* What is pinned, and why it matters:
* - The pure selection (`CodemanSecurityNotices.select`, constants.js), loaded from the
* SHIPPED file: a notice naming a CLI shows ONLY when `available[cli] === true` (absent,
* unknown and false all mean "not installed": a warning about software you do not have
* is noise), a notice with no CLI shows to everyone, dates are inclusive local days
* (start of `published` to the END of `expires`), seen ids are skipped, malformed
* entries and non-https links are dropped rather than repaired, and zh-CN takes the
* entry's own strings field by field.
* - The pure surface plan (`CodemanSecurityNotices.plan`): the update toast shows exactly
* when checkWhatsNew always showed it, every notice then rides inside it, and otherwise
* each notice gets its own card. A notice is never on both surfaces.
* - The shipped JSON itself: it parses, ids are unique, links are https, dates are real
* days, and every `cli` is a real stock registry id (a typo would silently show the
* notice to nobody).
* - The wiring (settings-ui.js checkWhatsNew + panels-ui.js cards): fresh installs and
* same-version reloads get the card, an existing install's update toast carries the
* notice, the update toast WITHOUT a notice is byte-for-byte the toast it was before,
* a notice is recorded as seen only on a user action (never on show), storage blocked
* shows nothing, the notice text is textContent and left alone by i18n.js, and the
* card's own copy is translated.
*
* The real modules run inside a JSDOM window (runScripts: 'outside-only'). Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const CONSTANTS = read('constants.js');
const NOTICES_FILE = JSON.parse(read('security-notices.json')) as { notices: Notice[] };
type Notice = Record<string, unknown>;
type Shown = { id: string; title: string; message: string; label: string; url: string };
type SelectOpts = { available?: Record<string, unknown>; seen?: Iterable<string>; now?: number; lang?: string };
type PlanState = { seenVersion?: string | null; runningVersion: string; notes?: unknown; notices?: unknown[] };
type Plan = { recordVersion: boolean; update: boolean; updateNotices: unknown[]; cards: unknown[] };
type Core = { select: (notices: unknown, opts?: SelectOpts) => Shown[]; plan: (state: PlanState) => Plan };
function loadCore(): Core {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(CONSTANTS, context, { filename: 'constants.js' });
return (context.window as { CodemanSecurityNotices: Core }).CodemanSecurityNotices;
}
const core = loadCore();
/** Local-time instants, the way the core reads `YYYY-MM-DD` days. */
const at = (y: number, m: number, d: number, h = 12, min = 0, s = 0, ms = 0) =>
new Date(y, m - 1, d, h, min, s, ms).getTime();
const NOW = at(2026, 10, 20);
const notice = (over: Notice = {}): Notice => ({
id: 'n1',
severity: 'warning',
published: '2026-10-18',
expires: '2026-12-31',
message: 'Rotate your keys.',
link: { label: 'Read more', url: 'https://example.com/notice' },
...over,
});
const ids = (shown: Array<{ id: string }>) => shown.map((n) => n.id);
describe('CodemanSecurityNotices.select (pure)', () => {
describe('a notice naming a CLI', () => {
const withCli = [notice({ cli: 'somecli' })];
it('shows when that CLI is installed (strictly true)', () => {
expect(ids(core.select(withCli, { available: { somecli: true }, now: NOW }))).toEqual(['n1']);
});
it('stays hidden when the CLI is not installed', () => {
expect(core.select(withCli, { available: { somecli: false }, now: NOW })).toEqual([]);
});
it('stays hidden when availability is unknown: no entry, no map, or a truthy non-true value', () => {
expect(core.select(withCli, { available: { other: true }, now: NOW })).toEqual([]);
expect(core.select(withCli, { now: NOW })).toEqual([]);
expect(core.select(withCli, { available: undefined, now: NOW })).toEqual([]);
expect(core.select(withCli, { available: { somecli: 'true' }, now: NOW })).toEqual([]);
expect(core.select(withCli, { available: { somecli: 1 }, now: NOW })).toEqual([]);
});
it('never matches a prototype key as an installed CLI', () => {
expect(core.select([notice({ cli: 'constructor' })], { available: {}, now: NOW })).toEqual([]);
});
});
it('shows a notice without a CLI to everyone, whatever is installed', () => {
expect(ids(core.select([notice()], { now: NOW }))).toEqual(['n1']);
expect(ids(core.select([notice()], { available: { somecli: false }, now: NOW }))).toEqual(['n1']);
});
it('returns exactly id, title, message, label and url, trimmed', () => {
const shown = core.select(
[
notice({
id: ' n1 ',
title: ' Heads up ',
message: ' Hi. ',
link: { label: ' Go ', url: ' https://example.com/x ' },
}),
],
{ now: NOW }
);
expect(shown).toEqual([{ id: 'n1', title: 'Heads up', message: 'Hi.', label: 'Go', url: 'https://example.com/x' }]);
});
it('gives a notice without a title an empty one', () => {
expect(core.select([notice()], { now: NOW })[0].title).toBe('');
});
describe('dates (inclusive local days)', () => {
const list = [notice()];
it('shows from the very start of the published day', () => {
expect(ids(core.select(list, { now: at(2026, 10, 18, 0, 0, 0, 0) }))).toEqual(['n1']);
expect(core.select(list, { now: at(2026, 10, 18, 0, 0, 0, 0) - 1 })).toEqual([]);
});
it('shows until the very end of the expires day, then never again', () => {
expect(ids(core.select(list, { now: at(2026, 12, 31, 23, 59, 59, 999) }))).toEqual(['n1']);
expect(core.select(list, { now: at(2026, 12, 31, 23, 59, 59, 999) + 1 })).toEqual([]);
expect(core.select(list, { now: at(2027, 6, 1) })).toEqual([]);
});
it('shows a one-day notice all of that day', () => {
const oneDay = [notice({ published: '2026-10-20', expires: '2026-10-20' })];
expect(ids(core.select(oneDay, { now: at(2026, 10, 20, 0, 0) }))).toEqual(['n1']);
expect(ids(core.select(oneDay, { now: at(2026, 10, 20, 23, 59, 59, 999) }))).toEqual(['n1']);
});
it('falls back to the current time when now is not a finite number', () => {
const always = [notice({ published: '2000-01-01', expires: '2999-12-31' })];
expect(ids(core.select(always))).toEqual(['n1']);
expect(ids(core.select(always, { now: Number.NaN }))).toEqual(['n1']);
});
});
it('skips the ids the user has already closed or followed', () => {
const list = [notice(), notice({ id: 'n2' })];
expect(ids(core.select(list, { seen: ['n1'], now: NOW }))).toEqual(['n2']);
expect(ids(core.select(list, { seen: new Set(['n2']), now: NOW }))).toEqual(['n1']);
expect(core.select(list, { seen: ['n1', 'n2'], now: NOW })).toEqual([]);
// A string is not a list of ids (it would iterate as characters).
expect(ids(core.select(list, { seen: 'n1' as unknown as string[], now: NOW }))).toEqual(['n1', 'n2']);
});
it('keeps file order and shows a repeated id once, as its first entry', () => {
const list = [notice({ id: 'b' }), notice({ id: 'a' }), notice({ id: 'b', message: 'Second.' })];
const shown = core.select(list, { now: NOW });
expect(ids(shown)).toEqual(['b', 'a']);
expect(shown[0].message).toBe('Rotate your keys.');
});
describe('malformed entries are skipped, never repaired', () => {
const cases: Array<[string, Notice | unknown]> = [
['no id', notice({ id: undefined })],
['a blank id', notice({ id: ' ' })],
['a numeric id', notice({ id: 7 })],
['no message', notice({ message: undefined })],
['a blank message', notice({ message: '' })],
['a blank title', notice({ title: ' ' })],
['a title that is not a string', notice({ title: 42 })],
['no link', notice({ link: undefined })],
['a link that is a string', notice({ link: 'https://example.com/' })],
['a link without a label', notice({ link: { url: 'https://example.com/' } })],
['a link without a url', notice({ link: { label: 'Go' } })],
['an http link', notice({ link: { label: 'Go', url: 'http://example.com/notice' } })],
['a javascript: link', notice({ link: { label: 'Go', url: 'javascript:alert(1)' } })],
['a protocol-relative link', notice({ link: { label: 'Go', url: '//example.com/' } })],
['a link with userinfo', notice({ link: { label: 'Go', url: 'https://user@evil.example/' } })],
['a link with whitespace inside', notice({ link: { label: 'Go', url: 'https://example.com/a b' } })],
['no published date', notice({ published: undefined })],
['no expires date', notice({ expires: undefined })],
['a month 13', notice({ expires: '2026-13-01' })],
['a day that does not exist', notice({ expires: '2026-02-30' })],
['a non-ISO date', notice({ published: '18.10.2026' })],
['a timestamp, not a day', notice({ published: '2026-10-18T00:00:00Z' })],
['a cli that is not a string', notice({ cli: 5 })],
['a cli that is not a registry id', notice({ cli: 'Open Code' })],
['an array', []],
['null', null],
['a string', 'notice'],
];
for (const [what, entry] of cases) {
it(`skips ${what}`, () => {
// The good entry beside it proves the bad one is skipped, not the whole list.
expect(ids(core.select([entry, notice({ id: 'ok' })], { available: { somecli: true }, now: NOW }))).toEqual([
'ok',
]);
});
}
it('returns nothing for a notices value that is not an array', () => {
expect(core.select(undefined, { now: NOW })).toEqual([]);
expect(core.select({ notices: [notice()] }, { now: NOW })).toEqual([]);
expect(core.select('[]', { now: NOW })).toEqual([]);
});
});
describe('language', () => {
const zh = { 'zh-CN': { title: '注意', message: '请更换密钥。', label: '了解更多' } };
it('takes the zh-CN title, message and label under zh-CN', () => {
const [shown] = core.select([notice({ title: 'Heads up', i18n: zh })], { lang: 'zh-CN', now: NOW });
expect(shown).toMatchObject({
title: '注意',
message: '请更换密钥。',
label: '了解更多',
url: 'https://example.com/notice',
});
});
it('falls back to English field by field when a translation is missing or blank', () => {
const onlyMessage = { 'zh-CN': { message: '请更换密钥。', label: ' ' } };
expect(
core.select([notice({ title: 'Heads up', i18n: onlyMessage })], { lang: 'zh-CN', now: NOW })[0]
).toMatchObject({ title: 'Heads up', message: '请更换密钥。', label: 'Read more' });
expect(core.select([notice()], { lang: 'zh-CN', now: NOW })[0]).toMatchObject({
message: 'Rotate your keys.',
label: 'Read more',
});
expect(core.select([notice({ i18n: 'nope' })], { lang: 'zh-CN', now: NOW })[0].message).toBe('Rotate your keys.');
});
it('stays English in English and in a language it has no strings for', () => {
expect(core.select([notice({ i18n: zh })], { lang: 'en', now: NOW })[0].message).toBe('Rotate your keys.');
expect(core.select([notice({ i18n: zh })], { now: NOW })[0].message).toBe('Rotate your keys.');
expect(core.select([notice({ i18n: zh })], { lang: 'fr', now: NOW })[0].message).toBe('Rotate your keys.');
});
});
});
describe('CodemanSecurityNotices.plan (pure): which surface carries each notice', () => {
const NOTES = { version: '1.43.0', highlights: ['Alpha'] };
const A = { id: 'a' };
const B = { id: 'b' };
it('puts every notice inside the update toast when an existing install sees a new version', () => {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES, notices: [A, B] })).toEqual({
recordVersion: true,
update: true,
updateNotices: [A, B],
cards: [],
});
});
it('shows the plain update toast when no notice is selected', () => {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES, notices: [] })).toEqual({
recordVersion: true,
update: true,
updateNotices: [],
cards: [],
});
});
it('gives a fresh install (no seen version) cards and records the version, with no update toast', () => {
for (const seenVersion of [null, undefined, '']) {
expect(core.plan({ seenVersion, runningVersion: '1.43.0', notes: NOTES, notices: [A] })).toEqual({
recordVersion: true,
update: false,
updateNotices: [],
cards: [A],
});
}
});
it('gives a reload on the same version cards and records nothing', () => {
expect(core.plan({ seenVersion: '1.43.0', runningVersion: '1.43.0', notes: NOTES, notices: [A] })).toEqual({
recordVersion: false,
update: false,
updateNotices: [],
cards: [A],
});
});
it('falls back to cards, recording no version, when the notes cannot carry a toast', () => {
for (const notes of [
null,
undefined,
{ version: '1.42.9', highlights: ['Old'] },
{ version: '1.43.0', highlights: [] },
{ version: '1.43.0', highlights: 'Alpha' },
{ version: '1.43.0' },
]) {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes, notices: [A] })).toEqual({
recordVersion: false,
update: false,
updateNotices: [],
cards: [A],
});
}
});
it('never shows one notice on both surfaces, and never drops one', () => {
const notices = [A, B];
for (const seenVersion of [null, '1.42.0', '1.43.0']) {
for (const notes of [null, NOTES, { version: '1.43.0', highlights: [] }]) {
const plan = core.plan({ seenVersion, runningVersion: '1.43.0', notes, notices });
const all = [...plan.updateNotices, ...plan.cards] as Array<{ id: string }>;
expect(ids(all).sort(), JSON.stringify({ seenVersion, notes })).toEqual(['a', 'b']);
expect(plan.updateNotices.length > 0 && plan.cards.length > 0).toBe(false);
}
}
});
it('tolerates a missing notices list', () => {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES }).updateNotices).toEqual([]);
});
});
describe('the shipped security-notices.json', () => {
const notices = NOTICES_FILE.notices;
const stockIds = new Set(STOCK_CLIS.map((entry) => entry.id as string));
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const realDay = (value: unknown) => {
if (typeof value !== 'string' || !DAY.test(value)) return false;
const [y, m, d] = value.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
};
const nonBlank = (value: unknown) => typeof value === 'string' && value.trim().length > 0;
it('is a list of notices with unique, stable ids', () => {
expect(Array.isArray(notices)).toBe(true);
expect(notices.length).toBeGreaterThan(0);
const all = notices.map((n) => n.id);
expect(new Set(all).size).toBe(all.length);
for (const id of all) expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
});
it('gives every notice a warning severity, an https link and real dates in order', () => {
for (const n of notices) {
expect(n.severity, String(n.id)).toBe('warning');
expect(nonBlank(n.message), String(n.id)).toBe(true);
if (n.title !== undefined) expect(nonBlank(n.title), String(n.id)).toBe(true);
const link = n.link as { label?: unknown; url?: unknown };
expect(nonBlank(link?.label), String(n.id)).toBe(true);
expect(typeof link?.url === 'string' && new URL(link.url).protocol, String(n.id)).toBe('https:');
expect(realDay(n.published), `${String(n.id)} published`).toBe(true);
expect(realDay(n.expires), `${String(n.id)} expires`).toBe(true);
expect(String(n.expires) >= String(n.published), String(n.id)).toBe(true);
}
});
it('names only real stock CLI ids, so a typo cannot hide a notice from everyone', () => {
for (const n of notices) {
if (n.cli === undefined) continue;
expect(stockIds.has(n.cli as string), `${String(n.id)}: cli "${String(n.cli)}"`).toBe(true);
}
});
it('translates only into languages the UI has, with every string the English entry has', () => {
for (const n of notices) {
if (n.i18n === undefined) continue;
const langs = n.i18n as Record<string, { title?: unknown; message?: unknown; label?: unknown }>;
for (const [lang, strings] of Object.entries(langs)) {
expect(lang, String(n.id)).toBe('zh-CN');
expect(nonBlank(strings.message), `${String(n.id)} ${lang} message`).toBe(true);
expect(nonBlank(strings.label), `${String(n.id)} ${lang} label`).toBe(true);
if (n.title !== undefined) expect(nonBlank(strings.title), `${String(n.id)} ${lang} title`).toBe(true);
}
}
});
it('passes the shipped selection on its published day for a browser with its CLI', () => {
// A field the core rejects would make the notice silently show to nobody.
for (const n of notices) {
const [y, m, d] = String(n.published).split('-').map(Number);
const available = typeof n.cli === 'string' ? { [n.cli]: true } : {};
expect(ids(core.select([n], { available, now: at(y, m, d) })), String(n.id)).toEqual([n.id]);
}
});
it('shows the OpenCode notice, with its title, only to OpenCode users', () => {
const breach = notices.find((n) => n.id === 'opencode-console-breach-2026-10');
expect(breach?.cli).toBe('opencode');
const [shown] = core.select(notices, { available: { opencode: true }, now: NOW });
expect(shown).toMatchObject({
id: 'opencode-console-breach-2026-10',
title: 'OpenCode Console breach: check your API keys',
label: "Read OpenCode's notice",
url: 'https://x.com/opencode/status/2111519637049319818',
});
expect(core.select(notices, { available: { opencode: true }, now: NOW, lang: 'zh-CN' })[0].title).toBe(
'OpenCode Console 遭入侵:请检查你的 API 密钥'
);
expect(core.select(notices, { available: { opencode: false, claude: true }, now: NOW })).toEqual([]);
expect(core.select(notices, { available: {}, now: NOW })).toEqual([]);
expect(core.select(notices, { available: { opencode: true }, now: at(2027, 1, 1) })).toEqual([]);
});
it('is fetched by the page and never generated by the release script', () => {
const pkg = readFileSync(join(process.cwd(), 'package.json'), 'utf8');
expect(pkg).not.toContain('security-notices');
expect(read('settings-ui.js')).toContain("fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' })");
});
});
// ─── Wiring: settings-ui.js checkWhatsNew + panels-ui.js notice cards ───
const SEEN_KEY = 'codeman-security-notices-seen';
const VERSION_KEY = 'codeman-whats-new-seen';
const RUNNING = '1.43.0';
const NOTES = { version: RUNNING, highlights: ['Alpha', 'Beta', 'Gamma', 'Delta'] };
const UPDATE_TEXT = 'Codeman updated to v1.43.0: Alpha · Beta · Gamma (+1 more)';
const RELEASE_URL = 'https://github.com/Ark0N/Codeman/releases/tag/codeman%401.43.0';
const DATA = {
notices: [
notice({
id: 'cli-notice',
cli: 'somecli',
title: 'Keys may be exposed',
i18n: { 'zh-CN': { title: '密钥可能泄露', message: '中文提示。', label: '查看' } },
}),
],
};
/** checkWhatsNew's plain update toast exactly as master rendered it before this feature. */
const PLAIN_UPDATE_TOAST =
'<div class="toast toast-info"><span class="toast-message">Codeman updated to v1.43.0: Alpha · Beta · Gamma (+1 more)</span>' +
'<button style="margin-left: 12px; padding: 2px 10px; background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 3px; color: inherit; cursor: pointer; font-size: 12px;">See what\'s new</button>' +
'<button class="toast-close" aria-label="Dismiss">×</button></div>';
type Reply = { ok: boolean; body?: unknown } | 'throw';
type WiredApp = {
checkWhatsNew(version: string): Promise<void>;
showSecurityNotice(notice: Shown, opts?: { onClose?: (reason: string) => void }): { dismiss(): void };
};
const windows: Array<{ close(): void }> = [];
afterEach(() => {
for (const win of windows.splice(0)) win.close();
vi.restoreAllMocks();
});
interface BootOpts {
available?: Record<string, unknown> | null;
storage?: 'blocked';
seenIds?: unknown;
/** The version this browser recorded; undefined = a fresh install. */
seenVersion?: string;
notices?: Reply;
notes?: Reply;
base?: string;
lang?: 'zh-CN';
i18n?: boolean;
}
/** constants.js, i18n.js (optional), settings-ui.js and panels-ui.js in one JSDOM page. */
function boot(opts: BootOpts = {}) {
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
url: 'http://localhost/',
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window as unknown as Window &
typeof globalThis & { eval(src: string): void; __App: { prototype: object }; [key: string]: unknown };
windows.push(win);
if (opts.seenIds !== undefined) win.localStorage.setItem(SEEN_KEY, JSON.stringify(opts.seenIds));
if (opts.seenVersion !== undefined) win.localStorage.setItem(VERSION_KEY, opts.seenVersion);
if (opts.storage === 'blocked') {
Object.defineProperty(win, 'localStorage', {
configurable: true,
get() {
throw new win.DOMException('blocked', 'SecurityError');
},
});
}
const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = [];
win.fetch = (async (url: string, init?: RequestInit) => {
fetchCalls.push({ url, init });
const reply: Reply = url.endsWith('/whats-new.json')
? (opts.notes ?? { ok: true, body: NOTES })
: (opts.notices ?? { ok: true, body: DATA });
if (reply === 'throw') throw new TypeError('offline');
return { ok: reply.ok, json: async () => reply.body };
}) as unknown as typeof fetch;
const opened: unknown[][] = [];
win.open = ((...args: unknown[]) => {
opened.push(args);
return null;
}) as typeof win.open;
if (opts.base) win.__CODEMAN_BASE__ = opts.base;
if (opts.available !== null) win.__codemanCliAvailable = opts.available ?? { somecli: true };
if (opts.lang) win.__codemanLanguage = opts.lang;
win.eval(
[
'var CodemanApp = function CodemanApp() {};',
CONSTANTS,
opts.i18n ? read('i18n.js') : '',
read('settings-ui.js'),
read('panels-ui.js'),
'window.__App = CodemanApp;',
].join('\n')
);
if (opts.i18n) (win.CodemanI18n as { start(): void }).start();
const app = Object.create(win.__App.prototype) as WiredApp;
const doc = win.document;
const cards = () => Array.from(doc.querySelectorAll<HTMLElement>('.security-notice:not(.update-notice)'));
const updateCards = () => Array.from(doc.querySelectorAll<HTMLElement>('.security-notice.update-notice'));
const plainToasts = () => Array.from(doc.querySelectorAll<HTMLElement>('.toast'));
const button = (root: Element, cls: string) => root.querySelector<HTMLButtonElement>(`button.${cls}`)!;
const text = (root: Element, cls: string) => root.querySelector(`.${cls}`)?.textContent ?? null;
const seenIds = () => JSON.parse(dom.window.localStorage.getItem(SEEN_KEY) || 'null') as unknown;
const seenVersion = () => dom.window.localStorage.getItem(VERSION_KEY);
return {
dom,
win,
doc,
app,
fetchCalls,
opened,
cards,
updateCards,
plainToasts,
button,
text,
seenIds,
seenVersion,
};
}
/** i18n.js translates inserted DOM from a MutationObserver: give it a tick. */
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const afterClose = () => new Promise((resolve) => setTimeout(resolve, 260));
describe('a browser with no update toast this load: the standalone card', () => {
it('greets a fresh install with the card, records the version, and does not fetch the notes', async () => {
const { app, fetchCalls, cards, updateCards, plainToasts, text, button, seenIds, seenVersion } = boot();
await app.checkWhatsNew(RUNNING);
expect(fetchCalls).toEqual([{ url: '/security-notices.json', init: { cache: 'no-cache' } }]);
expect(seenVersion()).toBe(RUNNING);
expect(plainToasts()).toEqual([]);
expect(updateCards()).toEqual([]);
const [card, ...rest] = cards();
expect(rest).toEqual([]);
expect(text(card, 'security-notice-eyebrow')).toBe('Security notice');
expect(text(card, 'security-notice-title')).toBe('Keys may be exposed');
expect(text(card, 'security-notice-body')).toBe('Rotate your keys.');
expect(button(card, 'security-notice-primary').textContent).toBe('Read more');
expect(button(card, 'security-notice-quiet').textContent).toBe('Dismiss');
expect(button(card, 'security-notice-close').getAttribute('aria-label')).toBe('Close');
// Nothing is recorded just for appearing.
expect(seenIds()).toBeNull();
});
it('shows it on a reload of the same version while it is unseen, and not once it is seen', async () => {
const unseen = boot({ seenVersion: RUNNING });
await unseen.app.checkWhatsNew(RUNNING);
expect(unseen.cards()).toHaveLength(1);
expect(unseen.fetchCalls.map((c) => c.url)).toEqual(['/security-notices.json']);
const seen = boot({ seenVersion: RUNNING, seenIds: ['cli-notice'] });
await seen.app.checkWhatsNew(RUNNING);
expect(seen.cards()).toEqual([]);
expect(seen.plainToasts()).toEqual([]);
});
it('is an accessible, sticky card in the toast stack, built from text only', async () => {
const hostile = {
notices: [
notice({
title: '<b>bold</b>',
message: '<img src=x onerror="alert(1)">',
link: { label: '<i>Go</i>', url: 'https://example.com/' },
}),
],
};
const { win, doc, app, cards, button } = boot({ notices: { ok: true, body: hostile } });
const timeout = vi.spyOn(win, 'setTimeout');
await app.checkWhatsNew(RUNNING);
const [card] = cards();
expect(card.parentElement?.className).toBe('toast-container');
expect(card.getAttribute('role')).toBe('alert');
expect(card.getAttribute('aria-live')).toBe('assertive');
expect(card.querySelector('.security-notice-icon svg')).not.toBeNull();
expect(card.querySelector('img, b, i')).toBeNull();
expect(card.querySelector('.security-notice-body')!.textContent).toBe('<img src=x onerror="alert(1)">');
for (const btn of card.querySelectorAll('button')) expect(btn.type).toBe('button');
expect(button(card, 'security-notice-primary').hasAttribute('data-i18n-skip')).toBe(true);
expect(timeout.mock.calls.filter(([, delay]) => Number(delay) > 0)).toEqual([]);
expect(doc.querySelectorAll('.toast-container').length).toBe(1);
});
for (const [how, cls, reason] of [
['the ×', 'security-notice-close', 'close'],
['Dismiss', 'security-notice-quiet', 'dismiss'],
] as const) {
it(`records the notice when the user closes it with ${how} (${reason}), and the next load stays quiet`, async () => {
const first = boot();
await first.app.checkWhatsNew(RUNNING);
const card = first.cards()[0];
first.button(card, cls).click();
first.button(card, cls).click(); // a double click while it fades: recorded once
expect(first.seenIds()).toEqual(['cli-notice']);
await afterClose();
expect(first.cards()).toEqual([]);
const next = boot({ seenVersion: RUNNING, seenIds: ['cli-notice'] });
await next.app.checkWhatsNew(RUNNING);
expect(next.cards()).toEqual([]);
});
}
it('opens the link with noopener, records the notice and closes the card', async () => {
const { app, cards, button, opened, seenIds } = boot({ seenIds: ['older-notice'] });
await app.checkWhatsNew(RUNNING);
button(cards()[0], 'security-notice-primary').click();
expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener']]);
expect(seenIds()).toEqual(['older-notice', 'cli-notice']);
await afterClose();
expect(cards()).toEqual([]);
});
it('merges with ids another tab recorded after this page loaded', async () => {
const { dom, app, cards, button, seenIds } = boot();
await app.checkWhatsNew(RUNNING);
dom.window.localStorage.setItem(SEEN_KEY, JSON.stringify(['from-another-tab']));
button(cards()[0], 'security-notice-close').click();
expect(seenIds()).toEqual(['from-another-tab', 'cli-notice']);
});
it('records nothing when the card is closed by code rather than by the user', async () => {
const { app } = boot();
const onClose = vi.fn();
app
.showSecurityNotice({ id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' }, { onClose })
.dismiss();
await afterClose();
expect(onClose).not.toHaveBeenCalled();
});
it('takes the card when the notes cannot carry an update toast, and records no version', async () => {
for (const notes of [
{ ok: false } as Reply,
'throw' as Reply,
{ ok: true, body: { version: '9.9.9', highlights: ['x'] } },
]) {
const { app, cards, updateCards, plainToasts, seenVersion } = boot({ seenVersion: '1.42.0', notes });
await app.checkWhatsNew(RUNNING);
expect(cards(), JSON.stringify(notes)).toHaveLength(1);
expect(updateCards()).toEqual([]);
expect(plainToasts()).toEqual([]);
expect(seenVersion()).toBe('1.42.0');
}
});
it('treats a corrupt seen value as nothing seen', async () => {
const { dom, app, cards, button, seenIds } = boot();
dom.window.localStorage.setItem(SEEN_KEY, '{not json');
await app.checkWhatsNew(RUNNING);
expect(cards()).toHaveLength(1);
button(cards()[0], 'security-notice-close').click();
expect(seenIds()).toEqual(['cli-notice']);
});
});
describe('an existing install that sees a new version: the notice rides inside the update toast', () => {
it('shows ONE notification with the release text, the warning section and both buttons', async () => {
const { win, app, cards, updateCards, plainToasts, text, seenIds, seenVersion } = boot({ seenVersion: '1.42.0' });
const timeout = vi.spyOn(win, 'setTimeout');
await app.checkWhatsNew(RUNNING);
expect(cards()).toEqual([]);
expect(plainToasts()).toEqual([]);
const [card, ...rest] = updateCards();
expect(rest).toEqual([]);
expect(card.getAttribute('role')).toBe('alert');
expect(text(card, 'update-notice-text')).toBe(UPDATE_TEXT);
const section = card.querySelector('.security-notice-section')!;
expect(section.querySelector('svg')).not.toBeNull();
expect(text(section, 'security-notice-eyebrow')).toBe('Security notice');
expect(text(section, 'security-notice-title')).toBe('Keys may be exposed');
expect(text(section, 'security-notice-body')).toBe('Rotate your keys.');
const labels = Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent);
expect(labels).toEqual(['Read more', "See what's new"]);
// The version is recorded on show, as checkWhatsNew always did; the notice is not.
expect(seenVersion()).toBe(RUNNING);
expect(seenIds()).toBeNull();
// Sticky: it carries a warning.
expect(timeout.mock.calls.filter(([, delay]) => Number(delay) > 0)).toEqual([]);
});
it('records the notice when the user closes it', async () => {
const { app, updateCards, button, seenIds } = boot({ seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
button(updateCards()[0], 'security-notice-close').click();
expect(seenIds()).toEqual(['cli-notice']);
await afterClose();
expect(updateCards()).toEqual([]);
});
it('records the notice and closes when the user follows the notice link', async () => {
const { app, updateCards, button, opened, seenIds } = boot({ seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
button(updateCards()[0], 'security-notice-primary').click();
expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener']]);
expect(seenIds()).toEqual(['cli-notice']);
await afterClose();
expect(updateCards()).toEqual([]);
});
it('carries two notices as two sections, and a link records only its own notice', async () => {
const two = {
notices: [
notice({ id: 'first', link: { label: 'First', url: 'https://example.com/1' } }),
notice({ id: 'second', link: { label: 'Second', url: 'https://example.com/2' } }),
],
};
const { app, updateCards, opened, seenIds } = boot({ seenVersion: '1.42.0', notices: { ok: true, body: two } });
await app.checkWhatsNew(RUNNING);
const [card] = updateCards();
expect(card.querySelectorAll('.security-notice-section')).toHaveLength(2);
const labels = Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent);
expect(labels).toEqual(['First', 'Second', "See what's new"]);
card.querySelectorAll<HTMLButtonElement>('.security-notice-primary')[1].click();
expect(opened).toEqual([['https://example.com/2', '_blank', 'noopener']]);
// The other notice was never answered, so it comes back next load (as a card).
expect(seenIds()).toEqual(['second']);
});
it('opens the release notes from "See what\'s new" and keeps the unanswered warning up', async () => {
const { app, updateCards, button, opened, seenIds } = boot({ seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
button(updateCards()[0], 'security-notice-secondary').click();
expect(opened).toEqual([[RELEASE_URL, '_blank', 'noopener']]);
expect(seenIds()).toBeNull();
await afterClose();
expect(updateCards()).toHaveLength(1);
});
it('keeps the update toast exactly as it was when no notice is selected', async () => {
for (const variant of [
{ available: { somecli: false } },
{ available: null },
{ seenIds: ['cli-notice'] },
{ notices: { ok: false } as Reply },
{ notices: 'throw' as Reply },
] as BootOpts[]) {
const { win, app, cards, updateCards, plainToasts, opened, seenVersion } = boot({
seenVersion: '1.42.0',
...variant,
});
const timeout = vi.spyOn(win, 'setTimeout');
await app.checkWhatsNew(RUNNING);
expect(cards(), JSON.stringify(variant)).toEqual([]);
expect(updateCards()).toEqual([]);
const toasts = plainToasts();
expect(toasts.map((t) => t.outerHTML)).toEqual([PLAIN_UPDATE_TOAST]);
expect(timeout.mock.calls.map(([, delay]) => delay)).toContain(20000);
expect(seenVersion()).toBe(RUNNING);
toasts[0].querySelector<HTMLButtonElement>('button:not(.toast-close)')!.click();
expect(opened).toEqual([[RELEASE_URL, '_blank', 'noopener']]);
}
});
});
describe('the gates every surface shares', () => {
it('shows nothing, and fetches nothing, when storage is blocked', async () => {
for (const seenVersion of [undefined, '1.42.0']) {
const { app, fetchCalls, cards, updateCards, plainToasts } = boot({ storage: 'blocked', seenVersion });
await app.checkWhatsNew(RUNNING);
expect(fetchCalls).toEqual([]);
expect([...cards(), ...updateCards(), ...plainToasts()]).toEqual([]);
}
});
it('shows nothing when the CLI is not installed or availability was never injected', async () => {
for (const available of [{ somecli: false }, {}, null]) {
const { app, cards, updateCards, seenIds } = boot({ available });
await app.checkWhatsNew(RUNNING);
expect([...cards(), ...updateCards()], JSON.stringify(available)).toEqual([]);
expect(seenIds()).toBeNull();
}
});
it('shows nothing when the notices file cannot be fetched or read', async () => {
for (const notices of [{ ok: false }, 'throw', { ok: true, body: { notices: 'x' } }] as Reply[]) {
const { app, cards } = boot({ notices });
await app.checkWhatsNew(RUNNING);
expect(cards(), JSON.stringify(notices)).toEqual([]);
}
});
it('checks once per page load, however many times init runs', async () => {
const { app, fetchCalls, cards } = boot({ seenVersion: RUNNING });
await app.checkWhatsNew(RUNNING);
await app.checkWhatsNew(RUNNING);
expect(fetchCalls).toHaveLength(1);
expect(cards()).toHaveLength(1);
});
it('fetches both files under a reverse-proxy base path, prefixed exactly once', async () => {
const { app, fetchCalls } = boot({ base: '/codeman', seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
expect(fetchCalls.map((c) => c.url).sort()).toEqual(['/codeman/security-notices.json', '/codeman/whats-new.json']);
});
});
describe('zh-CN', () => {
it("shows the card in the notice's own Chinese and translates the card's own copy", async () => {
const { app, cards, text, button } = boot({ lang: 'zh-CN', i18n: true });
await app.checkWhatsNew(RUNNING);
await tick();
const [card] = cards();
expect(text(card, 'security-notice-eyebrow')).toBe('安全提示');
expect(text(card, 'security-notice-title')).toBe('密钥可能泄露');
expect(text(card, 'security-notice-body')).toBe('中文提示。');
expect(button(card, 'security-notice-primary').textContent).toBe('查看');
expect(button(card, 'security-notice-quiet').textContent).toBe('忽略');
expect(button(card, 'security-notice-close').getAttribute('aria-label')).toBe('关闭');
for (const cls of ['security-notice-title', 'security-notice-body']) {
expect(card.querySelector(`.${cls}`)!.hasAttribute('data-i18n-skip'), cls).toBe(true);
}
});
it('translates the update text and "See what\'s new" inside the combined toast', async () => {
const { app, updateCards, text, button } = boot({ lang: 'zh-CN', i18n: true, seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
await tick();
const [card] = updateCards();
expect(text(card, 'update-notice-text')).toBe('Codeman 已更新到 v1.43.0:Alpha · Beta · Gamma(另有 1 项)');
expect(button(card, 'security-notice-secondary').textContent).toBe('查看更新内容');
expect(text(card, 'security-notice-body')).toBe('中文提示。');
});
it('leaves English notice text untranslated even when it matches a dictionary entry', async () => {
const plain = {
notices: [notice({ message: 'Settings saved', link: { label: 'Dismiss', url: 'https://example.com/' } })],
};
const { app, cards, text, button } = boot({ lang: 'zh-CN', i18n: true, notices: { ok: true, body: plain } });
await app.checkWhatsNew(RUNNING);
await tick();
expect(text(cards()[0], 'security-notice-body')).toBe('Settings saved');
expect(button(cards()[0], 'security-notice-primary').textContent).toBe('Dismiss');
});
});
describe('the card styles', () => {
const css = read('styles.css');
const block = css.slice(css.indexOf('.security-notice {'), css.indexOf('/* Notification Bell Button */'));
it('lives beside the toast rules and uses theme tokens, not fixed colours, for its ink', () => {
expect(css.indexOf('.toast-info {')).toBeLessThan(css.indexOf('.security-notice {'));
for (const token of ['var(--bg-card)', 'var(--text)', 'var(--text-dim)', 'var(--yellow)', 'var(--red)']) {
expect(block, token).toContain(token);
}
expect(block).not.toMatch(/(?<!rgba\()#[0-9a-f]{3,8}\b/i);
});
it('animates opacity and transform only, and nothing under prefers-reduced-motion', () => {
const rule = block.slice(0, block.indexOf('}'));
expect(rule).toMatch(/transition: opacity [^;]*, transform [^;]*;/);
expect(block).toMatch(
/@media \(prefers-reduced-motion: reduce\) \{\s*\.security-notice \{\s*transform: none;\s*transition: none;/
);
});
it('fills a phone width minus the stack gutters', () => {
expect(block).toMatch(/@media \(max-width: 599px\) \{\s*\.security-notice \{\s*width: calc\(100vw - 1rem\);/);
});
});