mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(ui): notice buttons act once, links send no referrer, solo windows defer
- A closing notice card stays in the DOM for its 200 ms fade, and its link and "See what's new" buttons still worked there: a double click opened the notice twice. close() now makes the card inert and every button handler does nothing once it is closing. - Notice links (and the combined card's "See what's new") open with noopener,noreferrer. noopener alone still sent this instance's origin, which can be a tailnet name or a tunnel URL, as the Referer. The plain update toast is unchanged. - The https rule is checked again at the button (CodemanSecurityNotices.urlOk): a caller handing showSecurityNotice or showUpdateNotice an unchecked URL gets no link button. - A detached session window (/session/:id) is rendered without __codemanCliAvailable, so it could never select the OpenCode notice, yet it took the update toast and recorded the version, and the dashboard then showed the notice as a separate card. While any notice is pending, a solo window now shows and records nothing and leaves both to the dashboard; with none pending it shows the plain toast as before. - The wiring tests pin the page's own clock (JSDOM gives the window its own Date). On the real clock 18 of them would have failed on every push from 2027-01-01, when the fixture notice expires. New tests pin the once-only close directly (the old double-click comment claimed more than it checked), the single open, the sink check, the solo windows, the scroll cap and the light-skin ink. - CLAUDE.md now says the public .json files are served no-cache, the exception to the year-long immutable cache. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ee6b115023
commit
82c34fba99
6 files changed
+184
-21
No files matched your search
@@ -44,7 +44,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
|
||||
3. **Only after verification passes**, proceed with COM
|
||||
|
||||
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
|
||||
The production server caches static files for 1 year, `immutable` (`setHeaders` in `server.ts`), except the `.json` data files under `public/` (`whats-new.json`, `security-notices.json`, `manifest.json`), which are served `no-cache` because they are neither content-hashed nor `?v=`-busted (pinned by `test/static-cache-headers.test.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
|
||||
|
||||
## COM Shorthand (Deployment)
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
|
||||
|
||||
**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (the light skins' `--yellow` is already a dark amber; the primary button's ink is `--bg-card`), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
|
||||
**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A detached session window (`/session/:id`) is rendered WITHOUT `__codemanCliAvailable`, so it cannot select a CLI notice: while any notice is pending (every CLI the file names counted as installed), it shows and records nothing and leaves the update toast and the notice to the dashboard, or it would record the version and the dashboard would show the notice as a separate card; with none pending it shows the plain toast as before. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (small amber text and the primary button use `--notice-ink`, which the light skins darken a step because their `--yellow` is under 4.5:1 for that text on white; the primary button's label is `--bg-card`), are capped to the room below the stack's top and scroll inside themselves (the container is fixed and cannot scroll, so the accent bar is a background layer, never a positioned child), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. A closing card is inert and its buttons do nothing during the fade; links open with `noopener,noreferrer` and the https rule is checked again at the button (`CodemanSecurityNotices.urlOk`). The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
|
||||
|
||||
## Frontend modules and UI subsystems
|
||||
|
||||
|
||||
@@ -3583,5 +3583,10 @@ function planStartupNotices(state = {}) {
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanSecurityNotices = { select: selectSecurityNotices, plan: planStartupNotices };
|
||||
window.CodemanSecurityNotices = {
|
||||
select: selectSecurityNotices,
|
||||
plan: planStartupNotices,
|
||||
// The same https-only rule select() applies, for the card's link button to check again.
|
||||
urlOk: (url) => typeof url === 'string' && SECURITY_NOTICE_URL_PATTERN.test(url),
|
||||
};
|
||||
}
|
||||
+30
-10
@@ -6298,10 +6298,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice'));
|
||||
main.append(...this._noticeTextBlock(notice));
|
||||
const actions = this._noticeEl('div', 'security-notice-actions');
|
||||
actions.append(
|
||||
this._noticeLinkButton(notice, () => card.close('link')),
|
||||
this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss'))
|
||||
);
|
||||
const link = this._noticeLinkButton(notice, card, () => card.close('link'));
|
||||
if (link) actions.append(link);
|
||||
actions.append(this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss')));
|
||||
main.append(actions);
|
||||
card.el.append(this._noticeIcon('shield', 'security-notice-icon'), main, card.closeButton);
|
||||
card.mount();
|
||||
@@ -6336,10 +6335,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice'));
|
||||
section.append(head, ...this._noticeTextBlock(notice));
|
||||
main.append(section);
|
||||
actions.append(this._noticeLinkButton(notice, () => { seen(notice.id); card.close('link'); }));
|
||||
const link = this._noticeLinkButton(notice, card, () => { seen(notice.id); card.close('link'); });
|
||||
if (link) actions.append(link);
|
||||
}
|
||||
actions.append(
|
||||
this._noticeButton('security-notice-secondary', "See what's new", () => window.open(releaseUrl, '_blank', 'noopener'))
|
||||
this._noticeButton(
|
||||
'security-notice-secondary',
|
||||
"See what's new",
|
||||
card.act(() => window.open(releaseUrl, '_blank', 'noopener,noreferrer'))
|
||||
)
|
||||
);
|
||||
main.append(actions);
|
||||
card.el.append(this._noticeIcon('update', 'security-notice-icon update-notice-icon'), main, card.closeButton);
|
||||
@@ -6347,7 +6351,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { dismiss: () => card.close(null) };
|
||||
},
|
||||
|
||||
/** The shell both notice cards share: role=alert, the ×, mount and a once-only close. */
|
||||
/**
|
||||
* The shell both notice cards share: role=alert, the ×, mount and a once-only close.
|
||||
* A closing card stays in the DOM for its 200 ms fade, so close() also makes it inert, and
|
||||
* `act(fn)` wraps a button's handler to do nothing once the card is closing (a double click
|
||||
* on a link used to open it twice; the × and Dismiss go through close(), already once-only).
|
||||
*/
|
||||
_noticeCard(className, onUserClose) {
|
||||
const el = this._noticeEl('div', className);
|
||||
el.setAttribute('role', 'alert');
|
||||
@@ -6356,6 +6365,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const close = (reason) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
el.inert = true;
|
||||
el.classList.remove('show');
|
||||
setTimeout(() => el.remove(), 200);
|
||||
if (reason && typeof onUserClose === 'function') {
|
||||
@@ -6368,7 +6378,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._toastStack().appendChild(el);
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
};
|
||||
return { el, closeButton, close, mount };
|
||||
const act = (fn) => () => { if (!closed) fn(); };
|
||||
return { el, closeButton, close, mount, act };
|
||||
},
|
||||
|
||||
/** The notice's own title (when it has one) and message: textContent, never translated. */
|
||||
@@ -6379,11 +6390,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
return parts;
|
||||
},
|
||||
|
||||
_noticeLinkButton(notice, after) {
|
||||
/**
|
||||
* The notice's link as the primary button, or null when the link is not https.
|
||||
* selectSecurityNotices() already drops such a notice; the URL is checked again here, at the
|
||||
* sink, because showSecurityNotice/showUpdateNotice take any object. Opened with noopener
|
||||
* AND noreferrer: noopener alone still sends this instance's origin (a tailnet name, a tunnel
|
||||
* URL) to the linked site as the Referer.
|
||||
*/
|
||||
_noticeLinkButton(notice, card, after) {
|
||||
const url = notice.url;
|
||||
if (!window.CodemanSecurityNotices?.urlOk?.(url)) return null;
|
||||
return this._noticeButton(
|
||||
'security-notice-primary',
|
||||
notice.label,
|
||||
() => { window.open(notice.url, '_blank', 'noopener'); after(); },
|
||||
card.act(() => { window.open(url, '_blank', 'noopener,noreferrer'); after(); }),
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
@@ -386,6 +386,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* fresh install or a reload on the same version (showSecurityNotice). With no notice
|
||||
* selected the update toast is the plain one it always was. Storage blocked shows neither:
|
||||
* nothing could remember a close.
|
||||
*
|
||||
* ⚠️ A detached session window (/session/:id) is rendered without __codemanCliAvailable, so
|
||||
* it can never select a notice naming a CLI. Were it to take the update toast anyway, it
|
||||
* would record the version and the dashboard would later show that notice as a separate
|
||||
* card, not inside the update toast. So while any notice is pending (_loadSecurityNotices
|
||||
* counts every CLI the file names as installed there), a solo window shows and records
|
||||
* nothing and leaves both to the dashboard; with none pending it shows the plain update
|
||||
* toast as it always did.
|
||||
*/
|
||||
async checkWhatsNew(runningVersion) {
|
||||
if (this._whatsNewChecked) return;
|
||||
@@ -398,6 +406,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null,
|
||||
this._loadSecurityNotices(),
|
||||
]);
|
||||
if (this.isSoloWindow && notices.length) return;
|
||||
const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices });
|
||||
if (plan.recordVersion) remember();
|
||||
if (plan.update) {
|
||||
@@ -443,7 +452,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* CodemanSecurityNotices.select() in constants.js: dates, the seen list, and for a notice
|
||||
* naming a CLI, `window.__codemanCliAvailable[cli] === true` only (unknown means not
|
||||
* installed). [] on any failure, and when storage is blocked (a close could not be
|
||||
* remembered, so the card would return on every load).
|
||||
* remembered, so the card would return on every load). In a solo window every CLI the file
|
||||
* names counts as installed: there the answer only decides whether to leave the release to
|
||||
* the dashboard (see checkWhatsNew), and is never shown.
|
||||
*/
|
||||
async _loadSecurityNotices() {
|
||||
let seen;
|
||||
@@ -452,8 +463,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data?.notices) ? data.notices : [];
|
||||
const available = this.isSoloWindow
|
||||
? Object.fromEntries(list.filter((entry) => typeof entry?.cli === 'string').map((entry) => [entry.cli, true]))
|
||||
: window.__codemanCliAvailable;
|
||||
return window.CodemanSecurityNotices.select(data?.notices, {
|
||||
available: window.__codemanCliAvailable,
|
||||
available,
|
||||
seen,
|
||||
now: Date.now(),
|
||||
lang: window.CodemanI18n?.language || window.__codemanLanguage,
|
||||
|
||||
@@ -434,8 +434,12 @@ const PLAIN_UPDATE_TOAST =
|
||||
|
||||
type Reply = { ok: boolean; body?: unknown } | 'throw';
|
||||
type WiredApp = {
|
||||
isSoloWindow?: boolean;
|
||||
checkWhatsNew(version: string): Promise<void>;
|
||||
showSecurityNotice(notice: Shown, opts?: { onClose?: (reason: string) => void }): { dismiss(): void };
|
||||
showUpdateNotice(opts: { text: string; releaseUrl: string; notices: Shown[]; onNoticeSeen?: (id: string) => void }): {
|
||||
dismiss(): void;
|
||||
};
|
||||
};
|
||||
|
||||
const windows: Array<{ close(): void }> = [];
|
||||
@@ -455,6 +459,8 @@ interface BootOpts {
|
||||
base?: string;
|
||||
lang?: 'zh-CN';
|
||||
i18n?: boolean;
|
||||
/** A detached session window (/session/:id), rendered without __codemanCliAvailable. */
|
||||
solo?: boolean;
|
||||
}
|
||||
|
||||
/** constants.js, i18n.js (optional), settings-ui.js and panels-ui.js in one JSDOM page. */
|
||||
@@ -467,6 +473,10 @@ function boot(opts: BootOpts = {}) {
|
||||
const win = dom.window as unknown as Window &
|
||||
typeof globalThis & { eval(src: string): void; __App: { prototype: object }; [key: string]: unknown };
|
||||
windows.push(win);
|
||||
// The page's own clock, pinned inside the fixtures' date window: JSDOM gives the window its
|
||||
// own Date (vi.setSystemTime never reaches it), and on the real clock every test that expects
|
||||
// a card would fail once the fixture's notice expired (2027-01-01).
|
||||
win.eval(`Date.now = () => ${NOW};`);
|
||||
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') {
|
||||
@@ -506,6 +516,7 @@ function boot(opts: BootOpts = {}) {
|
||||
);
|
||||
if (opts.i18n) (win.CodemanI18n as { start(): void }).start();
|
||||
const app = Object.create(win.__App.prototype) as WiredApp;
|
||||
if (opts.solo) app.isSoloWindow = true;
|
||||
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'));
|
||||
@@ -602,7 +613,7 @@ describe('a browser with no update toast this load: the standalone card', () =>
|
||||
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
|
||||
first.button(card, cls).click(); // a double click while it fades
|
||||
expect(first.seenIds()).toEqual(['cli-notice']);
|
||||
await afterClose();
|
||||
expect(first.cards()).toEqual([]);
|
||||
@@ -613,11 +624,41 @@ describe('a browser with no update toast this load: the standalone card', () =>
|
||||
});
|
||||
}
|
||||
|
||||
it('opens the link with noopener, records the notice and closes the card', async () => {
|
||||
it('reports a close once, however many times the card is clicked while it fades', async () => {
|
||||
for (const cls of ['security-notice-close', 'security-notice-quiet', 'security-notice-primary']) {
|
||||
const { app, cards, button, opened } = boot();
|
||||
const onClose = vi.fn();
|
||||
app.showSecurityNotice(
|
||||
{ id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' },
|
||||
{ onClose }
|
||||
);
|
||||
const card = cards()[0];
|
||||
button(card, cls).click();
|
||||
button(card, cls).click();
|
||||
button(card, 'security-notice-close').click();
|
||||
expect(onClose, cls).toHaveBeenCalledTimes(1);
|
||||
// A double click on the link opens it once, not twice.
|
||||
expect(opened, cls).toHaveLength(cls === 'security-notice-primary' ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws no link button for a link that is not https, whoever calls it', async () => {
|
||||
const { app, cards, opened } = boot();
|
||||
const onClose = vi.fn();
|
||||
app.showSecurityNotice({ id: 'x', title: '', message: 'm', label: 'Go', url: 'javascript:alert(1)' }, { onClose });
|
||||
const card = cards()[0];
|
||||
expect(card.querySelector('.security-notice-primary')).toBeNull();
|
||||
expect(Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent)).toEqual([
|
||||
'Dismiss',
|
||||
]);
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens the link with noopener and noreferrer, 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(opened).toEqual([['https://example.com/notice', '_blank', 'noopener,noreferrer']]);
|
||||
expect(seenIds()).toEqual(['older-notice', 'cli-notice']);
|
||||
await afterClose();
|
||||
expect(cards()).toEqual([]);
|
||||
@@ -704,7 +745,8 @@ describe('an existing install that sees a new version: the notice rides inside t
|
||||
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']]);
|
||||
button(updateCards()[0], 'security-notice-primary').click(); // a double click while it fades
|
||||
expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener,noreferrer']]);
|
||||
expect(seenIds()).toEqual(['cli-notice']);
|
||||
await afterClose();
|
||||
expect(updateCards()).toEqual([]);
|
||||
@@ -724,7 +766,7 @@ describe('an existing install that sees a new version: the notice rides inside t
|
||||
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']]);
|
||||
expect(opened).toEqual([['https://example.com/2', '_blank', 'noopener,noreferrer']]);
|
||||
// The other notice was never answered, so it comes back next load (as a card).
|
||||
expect(seenIds()).toEqual(['second']);
|
||||
});
|
||||
@@ -733,12 +775,26 @@ describe('an existing install that sees a new version: the notice rides inside t
|
||||
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(opened).toEqual([[RELEASE_URL, '_blank', 'noopener,noreferrer']]);
|
||||
expect(seenIds()).toBeNull();
|
||||
await afterClose();
|
||||
expect(updateCards()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports each notice once, and opens nothing, once the card is closing', async () => {
|
||||
const { app, updateCards, button, opened } = boot();
|
||||
const onNoticeSeen = vi.fn();
|
||||
const one = { id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' };
|
||||
app.showUpdateNotice({ text: 'Updated', releaseUrl: RELEASE_URL, notices: [one], onNoticeSeen });
|
||||
const card = updateCards()[0];
|
||||
button(card, 'security-notice-close').click();
|
||||
button(card, 'security-notice-close').click();
|
||||
button(card, 'security-notice-primary').click();
|
||||
button(card, 'security-notice-secondary').click();
|
||||
expect(onNoticeSeen.mock.calls).toEqual([['x']]);
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the update toast exactly as it was when no notice is selected', async () => {
|
||||
for (const variant of [
|
||||
{ available: { somecli: false } },
|
||||
@@ -807,6 +863,58 @@ describe('the gates every surface shares', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a detached session window (/session/:id), rendered without CLI availability', () => {
|
||||
it('leaves a pending notice and the update toast to the dashboard, recording nothing', async () => {
|
||||
for (const seenVersion of ['1.42.0', RUNNING, undefined]) {
|
||||
const {
|
||||
app,
|
||||
cards,
|
||||
updateCards,
|
||||
plainToasts,
|
||||
seenIds,
|
||||
seenVersion: version,
|
||||
} = boot({
|
||||
solo: true,
|
||||
available: null,
|
||||
seenVersion,
|
||||
});
|
||||
await app.checkWhatsNew(RUNNING);
|
||||
expect([...cards(), ...updateCards(), ...plainToasts()], String(seenVersion)).toEqual([]);
|
||||
expect(version()).toBe(seenVersion ?? null);
|
||||
expect(seenIds()).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('then lets the dashboard carry the notice inside its update toast', async () => {
|
||||
const { win, app, updateCards, cards } = boot({ solo: true, available: null, seenVersion: '1.42.0' });
|
||||
await app.checkWhatsNew(RUNNING);
|
||||
// The dashboard's load: same browser storage, availability injected.
|
||||
const dashboard = Object.create(Object.getPrototypeOf(app)) as WiredApp;
|
||||
win.__codemanCliAvailable = { somecli: true };
|
||||
await dashboard.checkWhatsNew(RUNNING);
|
||||
expect(cards()).toEqual([]);
|
||||
expect(updateCards()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the plain update toast as before when no notice is pending', async () => {
|
||||
for (const variant of [{ seenIds: ['cli-notice'] }, { notices: { ok: false } as Reply }] as BootOpts[]) {
|
||||
const { app, plainToasts, cards, seenVersion } = boot({
|
||||
solo: true,
|
||||
available: null,
|
||||
seenVersion: '1.42.0',
|
||||
...variant,
|
||||
});
|
||||
await app.checkWhatsNew(RUNNING);
|
||||
expect(
|
||||
plainToasts().map((t) => t.outerHTML),
|
||||
JSON.stringify(variant)
|
||||
).toEqual([PLAIN_UPDATE_TOAST]);
|
||||
expect(cards()).toEqual([]);
|
||||
expect(seenVersion()).toBe(RUNNING);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -869,4 +977,19 @@ describe('the card styles', () => {
|
||||
it('fills a phone width minus the stack gutters', () => {
|
||||
expect(block).toMatch(/@media \(max-width: 599px\) \{\s*\.security-notice \{\s*width: calc\(100vw - 1rem\);/);
|
||||
});
|
||||
|
||||
it('caps its height and scrolls inside itself, since the fixed stack cannot scroll', () => {
|
||||
const rule = block.slice(0, block.indexOf('}'));
|
||||
expect(rule).toContain('max-height: calc(var(--app-height, 100dvh) - var(--header-height) - 1rem);');
|
||||
expect(rule).toContain('overflow-y: auto;');
|
||||
// A positioned accent bar would scroll away with the content.
|
||||
expect(block).not.toContain('.security-notice::before');
|
||||
});
|
||||
|
||||
it('darkens the amber ink on the light skins only', () => {
|
||||
expect(block).toContain('--notice-ink: var(--yellow);');
|
||||
expect(block).toMatch(
|
||||
/html:is\(\[data-skin="paper-gray"\], \[data-skin="solarized-light"\], \[data-skin="catppuccin-latte"\], \[data-skin="rose-pine-dawn"\]\) \.security-notice \{\s*--notice-ink: color-mix\(in srgb, var\(--yellow\) 85%, black\);/
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user