diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index b1498478..7d4c8724 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -122,6 +122,7 @@ every call, and the absolute path built from it is used and discarded there. | `open-external` | Opens https:// URLs in OS browser | | `clipboard-write-text` | Main-process clipboard write (Wayland fix, PR #18) | | `get-app-version` | From package.json | +| `whats-new-startup` / `whats-new-dismissed` | The What's new dialog: the `CHANGELOG.md` sections to show on startup (or `null`), and recording the running version as `lastSeenVersion` when it closes. Help → What's new sends `show-whats-new` with the running version's section. See `docs/changelog.md` | | `updater-check` / `updater-download` / `updater-install` | electron-updater | ### Send (fire-and-forget, renderer → main) @@ -136,7 +137,7 @@ every call, and the absolute path built from it is used and discarded there. ### Events (main → renderer) -`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `session-transcript-activity` +`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `show-whats-new`, `session-transcript-activity` `session-transcript-activity` and (not listed above; see `.ai/contexts/session-cache.md`, "Remote hosts — busy spinner") `remote-activity` diff --git a/.ai/contexts/window-frame.md b/.ai/contexts/window-frame.md index 1b39e4bd..5c43abb8 100644 --- a/.ai/contexts/window-frame.md +++ b/.ai/contexts/window-frame.md @@ -11,7 +11,7 @@ the same strip across the rest of the window. | File | Role | |---|---| -| `window-frame.js` | `windowFrameOptions(platform)` (the `BrowserWindow` options), `applicationMenuTemplate(appName)` (the menu), `KEYBOARD_ROLES`, `STRIP_HEIGHT`, `zoomKey()` and `nextZoomLevel()` (the zoom keys), `menuPopupPoint()` (where the menu button pops the menu up). | +| `window-frame.js` | `windowFrameOptions(platform)` (the `BrowserWindow` options), `applicationMenuTemplate(appName, { onWhatsNew })` (the menu; Help → What's new calls `onWhatsNew`, see `docs/changelog.md`), `KEYBOARD_ROLES`, `STRIP_HEIGHT`, `zoomKey()` and `nextZoomLevel()` (the zoom keys), `menuPopupPoint()` (where the menu button pops the menu up). | | `main.js` | Spreads `windowFrameOptions(process.platform)` into the `BrowserWindow`; `buildMenu()` installs the template; the `popup-app-menu` IPC opens it under the menu button. | | `preload.js` | `window.api.popupAppMenu(x, y)`. | | `public/window-strip.js` | Marks `` with `window-frameless`, `platform-` and, while full screen, `window-full-screen`; wires `#app-menu-btn`. Dual-mode: a classic ` + diff --git a/public/setting-defaults.js b/public/setting-defaults.js index 403671ba..a9e25641 100644 --- a/public/setting-defaults.js +++ b/public/setting-defaults.js @@ -30,6 +30,7 @@ const SETTING_DEFAULTS = { autoUpdate: true, shellProfile: 'auto', activityReporting: false, // see .ai/contexts/activitywatch.md + lastSeenVersion: null, // see docs/changelog.md }; if (typeof module !== 'undefined' && module.exports) { diff --git a/public/style.css b/public/style.css index 9add0dce..fa4eaf08 100644 --- a/public/style.css +++ b/public/style.css @@ -3393,6 +3393,111 @@ body { display: flex; flex-direction: column; } display: none; } +/* ========== WHAT'S NEW DIALOG ========== */ +.whats-new-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.whats-new-dialog { + background: #1e1e2e; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + width: 560px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} + +.whats-new-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--hairline); +} + +.whats-new-header h2 { + font-size: 14px; + font-weight: 600; + color: #d0d0e0; +} + +.whats-new-close { + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + width: 26px; + height: 26px; + color: var(--text-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.whats-new-close:hover { + background: var(--control-surface); + border-color: var(--control-border); + color: #d0d0e0; +} + +.whats-new-body { + overflow-y: auto; + padding: 4px 20px 16px; + font-size: 13px; + line-height: 1.5; + color: #c0c0d0; +} +.whats-new-body::-webkit-scrollbar { width: 5px; } +.whats-new-body::-webkit-scrollbar-track { background: transparent; } +.whats-new-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; } +.whats-new-body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.1); } + +.whats-new-section h3 { + margin: 14px 0 4px; + font-size: 13px; + font-weight: 600; + color: var(--accent); +} + +.whats-new-section h4 { + margin: 10px 0 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.whats-new-section ul { + margin: 0 0 4px; + padding-left: 18px; +} + +.whats-new-section li { margin: 3px 0; } +.whats-new-section p { margin: 6px 0; } + +.whats-new-section code { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 12px; + background: var(--control-surface); + border: 1px solid var(--control-border); + border-radius: 4px; + padding: 0 4px; +} + +.whats-new-section strong { color: #e0e0e0; } + +.whats-new-link { color: var(--accent); text-decoration: none; } +.whats-new-link:hover { text-decoration: underline; } + /* ========== GLOBAL SETTINGS BUTTON ========== */ #global-settings-btn { margin-left: auto; @@ -4979,6 +5084,6 @@ body.window-frameless #sidebar.collapsed ~ #main :is( body.window-frameless :is(button, input, select, textarea, a, [role="button"], [contenteditable]), body.window-frameless :is(#terminal-header-status, #terminal-header-id, #terminal-header-controls [data-header-kind], #jsonl-viewer-session-id, .viewer-toolbar-path), -body.window-frameless :is(.new-session-popover, .terminal-context-menu, .new-session-overlay, .add-project-overlay, .jsonl-screenshot-fullscreen, #update-toast, .restore-toast) { +body.window-frameless :is(.new-session-popover, .terminal-context-menu, .new-session-overlay, .add-project-overlay, .whats-new-overlay, .jsonl-screenshot-fullscreen, #update-toast, .restore-toast) { -webkit-app-region: no-drag; } diff --git a/public/whats-new.js b/public/whats-new.js new file mode 100644 index 00000000..2d30e17d --- /dev/null +++ b/public/whats-new.js @@ -0,0 +1,107 @@ +// see docs/changelog.md +'use strict'; + +const LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^\s()"'<>]+)\)/g; +const BOLD_RE = /\*\*([^*]+)\*\*/g; + +function renderInline(text, escape) { + return text.split(/`([^`]+)`/).map((part, i) => { + if (i % 2 === 1) return `${escape(part)}`; + return escape(part) + .replace(BOLD_RE, '$1') + .replace(LINK_RE, '$1'); + }).join(''); +} + +function renderChangelogMarkdown(md, escape) { + const out = []; + let list = null; + let para = null; + const flush = () => { + if (list) out.push(``); + if (para) out.push(`

${renderInline(para.join(' '), escape)}

`); + list = null; + para = null; + }; + for (const line of md.split(/\r?\n/)) { + const bullet = /^[-*] (.*)$/.exec(line); + if (line.trim() === '') { + flush(); + } else if (line.startsWith('### ')) { + flush(); + out.push(`

${renderInline(line.slice(4).trim(), escape)}

`); + } else if (bullet) { + if (para) flush(); + list = list || []; + list.push(bullet[1].trim()); + } else if (list && /^\s/.test(line)) { + list[list.length - 1] += ' ' + line.trim(); + } else { + if (list) flush(); + para = para || []; + para.push(line.trim()); + } + } + flush(); + return out.join(''); +} + +function showWhatsNew(doc, api, escape, payload) { + if (doc.querySelector('.whats-new-overlay')) return null; + + const overlay = doc.createElement('div'); + overlay.className = 'whats-new-overlay'; + const sections = payload.sections.map((s) => ` +
+

v${escape(s.version)} — ${escape(s.date)}

+ ${renderChangelogMarkdown(s.body, escape)} +
`).join(''); + overlay.innerHTML = ` + `; + doc.body.appendChild(overlay); + + function close() { + overlay.remove(); + doc.removeEventListener('keydown', onKey, true); + api.whatsNewDismissed(); + } + function onKey(e) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(); + } + } + + overlay.querySelector('.whats-new-close').addEventListener('click', close); + overlay.addEventListener('click', (e) => { + const link = e.target.closest('a.whats-new-link'); + if (link) { + e.preventDefault(); + api.openExternal(link.getAttribute('href')); + } else if (e.target === overlay) { + close(); + } + }); + doc.addEventListener('keydown', onKey, true); + overlay.querySelector('.whats-new-close').focus(); + return overlay; +} + +async function initWhatsNew(doc, api, escape) { + api.onShowWhatsNew((payload) => showWhatsNew(doc, api, escape, payload)); + const payload = await api.whatsNewStartup(); + if (payload) showWhatsNew(doc, api, escape, payload); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { renderChangelogMarkdown, showWhatsNew, initWhatsNew }; +} else { + initWhatsNew(document, window.api, escapeHtml); +} diff --git a/scripts/changelog-section.js b/scripts/changelog-section.js new file mode 100644 index 00000000..20b2d64c --- /dev/null +++ b/scripts/changelog-section.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// see docs/releasing.md +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { parseChangelog, compareVersions } = require('../changelog'); + +function releaseNotes(text, tag) { + const version = tag.replace(/^v/, ''); + const section = parseChangelog(text).versions.find((v) => compareVersions(v.version, version) === 0); + if (!section) throw new Error(`CHANGELOG.md has no "## v${version} — YYYY-MM-DD" section`); + if (!section.body) throw new Error(`the CHANGELOG.md section for v${version} is empty`); + return section.body; +} + +if (require.main === module) { + try { + const text = fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf8'); + process.stdout.write(releaseNotes(text, process.argv[2] || '') + '\n'); + } catch (err) { + console.error(`::error::${err.message}`); + process.exitCode = 1; + } +} + +module.exports = { releaseNotes }; diff --git a/scripts/check-changelog.js b/scripts/check-changelog.js new file mode 100644 index 00000000..2ee40e2b --- /dev/null +++ b/scripts/check-changelog.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// see docs/changelog.md ("The CI check") +'use strict'; + +const { execFileSync } = require('child_process'); + +const WAIVER_LABEL = 'no-changelog'; + +function isAppPath(file) { + if (file.startsWith('public/') || file.startsWith('workers/')) return true; + if (file === 'scripts/claude-sandbox.sh') return true; + return /^[^/]+\.js$/.test(file) && file !== 'eslint.config.js'; +} + +function checkChangelog({ files, labels }) { + const app = files.filter(isAppPath); + if (app.length === 0 || files.includes('CHANGELOG.md') || labels.includes(WAIVER_LABEL)) { + return { ok: true, message: 'changelog check passed' }; + } + return { + ok: false, + message: [ + 'This PR changes the app without an entry in CHANGELOG.md:', + ...app.map((f) => ` ${f}`), + 'Add one under "## Unreleased" (see docs/changelog.md), or add the', + `"${WAIVER_LABEL}" label if users see no difference, then re-run this job.`, + ].join('\n'), + }; +} + +function parseLabels(raw) { + if (!raw) return []; + let labels; + try { labels = JSON.parse(raw); } catch { labels = null; } + if (!Array.isArray(labels) || !labels.every((l) => typeof l === 'string')) { + throw new Error(`PR_LABELS must be a JSON array of label names, got: ${raw}`); + } + return labels; +} + +const USAGE = 'usage: check-changelog.js --base --head (--labels | --repo --pr )'; + +function parseArgs(argv) { + const opts = {}; + for (let i = 0; i < argv.length; i += 2) { + if (!argv[i].startsWith('--') || argv[i + 1] === undefined) throw new Error(USAGE); + opts[argv[i].slice(2)] = argv[i + 1]; + } + if (!opts.base || !opts.head) throw new Error(USAGE); + if (opts.labels === undefined && !(opts.repo && opts.pr)) throw new Error(USAGE); + return opts; +} + +function changedFiles(base, head) { + return execFileSync('git', ['diff', '--name-only', '--no-renames', `${base}...${head}`], { encoding: 'utf8' }) + .split(/\r?\n/).map((l) => l.trim()).filter(Boolean); +} + +function prLabels(repo, pr) { + const out = execFileSync('gh', ['api', `repos/${repo}/issues/${pr}/labels`, '--jq', '[.[].name]'], { encoding: 'utf8' }); + return parseLabels(out.trim()); +} + +if (require.main === module) { + try { + const opts = parseArgs(process.argv.slice(2)); + const labels = opts.labels !== undefined ? parseLabels(opts.labels) : prLabels(opts.repo, opts.pr); + const result = checkChangelog({ files: changedFiles(opts.base, opts.head), labels }); + console.log(result.message); + process.exitCode = result.ok ? 0 : 1; + } catch (err) { + console.error(err.message); + process.exitCode = 2; + } +} + +module.exports = { isAppPath, checkChangelog, parseLabels }; diff --git a/test/changelog-section.test.js b/test/changelog-section.test.js new file mode 100644 index 00000000..3a8af874 --- /dev/null +++ b/test/changelog-section.test.js @@ -0,0 +1,43 @@ +// scripts/changelog-section.js: the release notes are the tag's CHANGELOG.md section — see docs/releasing.md. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { releaseNotes } = require('../scripts/changelog-section'); + +const ROOT = path.join(__dirname, '..'); + +const TEXT = [ + '# Changelog', '', + '## Unreleased', '', '### New', '- Later. (#3)', '', + '## v0.0.2 — 2026-10-02', '', '### Fixed', '- Two. (#2)', '', + '## v0.0.1 — 2026-10-01', '', '', + '---', '', 'Older versions: see GitHub Releases.', '', +].join('\n'); + +test('the notes of a tag are its section, without the version heading', () => { + assert.equal(releaseNotes(TEXT, 'v0.0.2'), '### Fixed\n- Two. (#2)'); + assert.equal(releaseNotes(TEXT, '0.0.2'), '### Fixed\n- Two. (#2)'); +}); + +test('a tag with no section is an error that says what to do', () => { + assert.throws(() => releaseNotes(TEXT, 'v0.0.3'), /no "## v0\.0\.3 — YYYY-MM-DD" section/); +}); + +test('a tag whose section is empty is an error, never an empty body', () => { + assert.throws(() => releaseNotes(TEXT, 'v0.0.1'), /section for v0\.0\.1 is empty/); +}); + +test('the Unreleased section is never published as a version', () => { + assert.throws(() => releaseNotes(TEXT.replace('## v0.0.2 — 2026-10-02', '## v0.0.9 — 2026-10-02'), 'v0.0.2'), /no "## v0\.0\.2/); +}); + +test('the build workflow writes the release body from the tag\'s changelog section', () => { + const workflow = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'build.yml'), 'utf8'); + const step = workflow.slice(workflow.indexOf('- name: Fill in release notes')); + assert.match(step, /node scripts\/changelog-section\.js "\$TAG" > release-notes\.md/); + assert.match(step, /gh release edit "\$TAG" --notes-file release-notes\.md/); +}); diff --git a/test/changelog.test.js b/test/changelog.test.js new file mode 100644 index 00000000..e55d6455 --- /dev/null +++ b/test/changelog.test.js @@ -0,0 +1,295 @@ +// CHANGELOG.md parsing and the What's new decisions — see docs/changelog.md. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + compareVersions, + parseChangelog, + sectionsBetween, + whatsNewOnStartup, + whatsNewForVersion, + createWhatsNew, + VERSION_BEFORE_WHATS_NEW, +} = require('../changelog'); + +const ROOT = path.join(__dirname, '..'); + +const SAMPLE = [ + '# Changelog', + '', + 'Intro line.', + '', + '## Unreleased', + '', + '### New', + '- Pending thing. (#9)', + '', + '## v0.0.100 — 2026-10-03', + '', + '### Fixed', + '- Hundredth. (#8)', + '', + '## v0.0.99 — 2026-10-02', + '', + '### New', + '- Ninety-nine. (#7)', + '', + '## v0.0.98 — 2026-10-01', + '', + '### Changed', + '- Ninety-eight. (#6)', + '', + '---', + '', + 'Older versions: see GitHub Releases.', + '', +].join('\n'); + +test('compareVersions orders numerically, not as text: 0.0.100 is greater than 0.0.99', () => { + assert.equal(compareVersions('0.0.100', '0.0.99'), 1); + assert.equal(compareVersions('0.0.99', '0.0.100'), -1); + assert.equal(compareVersions('0.1.0', '0.0.999'), 1); + assert.equal(compareVersions('1.0.0', '0.99.99'), 1); + assert.equal(compareVersions('0.0.84', '0.0.84'), 0); +}); + +test('compareVersions accepts a leading v on either side', () => { + assert.equal(compareVersions('v0.0.85', '0.0.84'), 1); + assert.equal(compareVersions('0.0.84', 'v0.0.84'), 0); +}); + +test('compareVersions throws on something that is not X.Y.Z', () => { + for (const bad of ['', '0.0', '0.0.x', 'latest', null, undefined, 84, '0.0.84.1']) { + assert.throws(() => compareVersions(bad, '0.0.1'), TypeError, `accepted ${String(bad)}`); + assert.throws(() => compareVersions('0.0.1', bad), TypeError, `accepted ${String(bad)}`); + } +}); + +test('parseChangelog reads the Unreleased body and each version with its date, in file order', () => { + const parsed = parseChangelog(SAMPLE); + assert.equal(parsed.unreleased, '### New\n- Pending thing. (#9)'); + assert.deepEqual(parsed.versions.map((v) => [v.version, v.date]), [ + ['0.0.100', '2026-10-03'], + ['0.0.99', '2026-10-02'], + ['0.0.98', '2026-10-01'], + ]); + assert.equal(parsed.versions[1].body, '### New\n- Ninety-nine. (#7)'); +}); + +test('parseChangelog ends the last section at the closing rule, so the pointer line is not part of it', () => { + const last = parseChangelog(SAMPLE).versions.at(-1); + assert.equal(last.body, '### Changed\n- Ninety-eight. (#6)'); +}); + +test('parseChangelog accepts CRLF line endings', () => { + const parsed = parseChangelog(SAMPLE.replace(/\n/g, '\r\n')); + assert.equal(parsed.versions[0].body, '### Fixed\n- Hundredth. (#8)'); +}); + +test('parseChangelog throws on a file with no version section', () => { + assert.throws(() => parseChangelog('# Changelog\n\n## Unreleased\n\n- x\n'), /no version section/); + assert.throws(() => parseChangelog(''), /no version section/); +}); + +test('parseChangelog throws on a second-level heading that is neither Unreleased nor a version', () => { + assert.throws(() => parseChangelog(SAMPLE.replace('## v0.0.99 — 2026-10-02', '## Version 99')), /malformed heading/); + assert.throws(() => parseChangelog(SAMPLE.replace('## v0.0.99 — 2026-10-02', '## v0.0.99')), /malformed heading/); +}); + +test('parseChangelog throws on something that is not a string', () => { + assert.throws(() => parseChangelog(null), TypeError); + assert.throws(() => parseChangelog(Buffer.from(SAMPLE)), TypeError); +}); + +test('sectionsBetween keeps every version after the last seen, up to the current one, skipped ones included, newest first', () => { + const { versions } = parseChangelog(SAMPLE); + assert.deepEqual(sectionsBetween(versions, '0.0.97', '0.0.100').map((v) => v.version), ['0.0.100', '0.0.99', '0.0.98']); + assert.deepEqual(sectionsBetween(versions, '0.0.98', '0.0.100').map((v) => v.version), ['0.0.100', '0.0.99']); +}); + +test('sectionsBetween excludes the last seen version and anything newer than the current one', () => { + const { versions } = parseChangelog(SAMPLE); + assert.deepEqual(sectionsBetween(versions, '0.0.98', '0.0.99').map((v) => v.version), ['0.0.99']); + assert.deepEqual(sectionsBetween(versions, '0.0.99', '0.0.99'), []); +}); + +test('sectionsBetween sorts newest first whatever the file order', () => { + const { versions } = parseChangelog(SAMPLE); + const shuffled = [versions[2], versions[0], versions[1]]; + assert.deepEqual(sectionsBetween(shuffled, '0.0.1', '0.0.100').map((v) => v.version), ['0.0.100', '0.0.99', '0.0.98']); +}); + +function reader(text) { + let calls = 0; + const read = () => { calls++; return text; }; + read.calls = () => calls; + return read; +} + +test('startup after an update shows the sections since the last seen version and records nothing yet', () => { + const read = reader(SAMPLE); + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion: '0.0.98', readChangelog: read }); + assert.deepEqual(result.sections.map((s) => s.version), ['0.0.100', '0.0.99']); + assert.equal(result.record, null); + assert.equal(result.error, null); +}); + +test('startup on a fresh install shows nothing, records the current version and never reads the file', () => { + for (const lastSeenVersion of [undefined, null, '']) { + const read = reader(SAMPLE); + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion, existingInstall: false, readChangelog: read }); + assert.equal(result.sections, null); + assert.equal(result.record, '0.0.100'); + assert.equal(read.calls(), 0); + } +}); + +test('startup on a fresh install with an unreadable stored version records the current one and shows nothing', () => { + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion: 'garbage', existingInstall: false, readChangelog: reader(SAMPLE) }); + assert.equal(result.sections, null); + assert.equal(result.record, '0.0.100'); +}); + +test('an existing install with no lastSeenVersion is shown every section after the last version without the dialog', () => { + assert.equal(VERSION_BEFORE_WHATS_NEW, '0.0.84'); + const text = SAMPLE.replace('## v0.0.98 — 2026-10-01', '## v0.0.84 — 2026-09-29'); + for (const lastSeenVersion of [undefined, null, '', 'garbage']) { + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion, existingInstall: true, readChangelog: reader(text) }); + assert.deepEqual(result.sections.map((s) => s.version), ['0.0.100', '0.0.99'], `lastSeenVersion=${String(lastSeenVersion)}`); + assert.equal(result.record, null); + } +}); + +test('an existing install still on the last version without the dialog shows nothing and never reads the file', () => { + const read = reader(SAMPLE); + const result = whatsNewOnStartup({ currentVersion: '0.0.84', lastSeenVersion: undefined, existingInstall: true, readChangelog: read }); + assert.equal(result.sections, null); + assert.equal(result.record, null); + assert.equal(read.calls(), 0); +}); + +function memorySettings(initial = {}) { + const rows = new Map(Object.entries(initial).map(([k, v]) => [k, JSON.stringify(v)])); + return { + getSetting: (key) => (rows.has(key) ? JSON.parse(rows.get(key)) : null), + setSetting: (key, value) => { rows.set(key, JSON.stringify(value)); }, + }; +} + +function service(settings, overrides = {}) { + const warnings = []; + const whatsNew = createWhatsNew({ + ...settings, + currentVersion: '0.0.100', + existingInstall: true, + lastSeenDefault: null, + readChangelog: reader(SAMPLE), + log: { warn: (msg) => warnings.push(msg) }, + ...overrides, + }); + return { whatsNew, warnings }; +} + +test('dismissing the dialog persists the running version, so the next start shows nothing', () => { + const settings = memorySettings({ global: { lastSeenVersion: '0.0.98', windowBounds: { x: 1 } } }); + const first = service(settings).whatsNew; + assert.deepEqual(first.startup().sections.map((s) => s.version), ['0.0.100', '0.0.99']); + first.dismissed(); + assert.deepEqual(settings.getSetting('global'), { lastSeenVersion: '0.0.100', windowBounds: { x: 1 } }); + assert.equal(service(settings).whatsNew.startup(), null); +}); + +test('the dialog shown on startup comes back on the next start if it was never dismissed', () => { + const settings = memorySettings({ global: { lastSeenVersion: '0.0.98' } }); + assert.ok(service(settings).whatsNew.startup()); + assert.ok(service(settings).whatsNew.startup()); +}); + +test('a fresh install records the running version on its first start, and shows nothing on the next', () => { + const settings = memorySettings(); + assert.equal(service(settings, { existingInstall: false }).whatsNew.startup(), null); + assert.equal(settings.getSetting('global').lastSeenVersion, '0.0.100'); + assert.equal(service(settings).whatsNew.startup(), null); +}); + +test('startup and the menu log a broken changelog and write nothing', () => { + const settings = memorySettings({ global: { lastSeenVersion: '0.0.98' } }); + const { whatsNew, warnings } = service(settings, { readChangelog: reader('## nonsense') }); + assert.equal(whatsNew.startup(), null); + assert.equal(whatsNew.forMenu(), null); + assert.equal(warnings.length, 2); + assert.match(warnings[0], /\[whats-new\] malformed heading/); + assert.deepEqual(settings.getSetting('global'), { lastSeenVersion: '0.0.98' }); +}); + +test('the menu gives the running version section with the version it belongs to', () => { + const { whatsNew } = service(memorySettings()); + assert.deepEqual(whatsNew.forMenu(), { version: '0.0.100', sections: [parseChangelog(SAMPLE).versions[0]] }); +}); + +test('startup on the same or an older version shows nothing and records nothing', () => { + for (const lastSeenVersion of ['0.0.100', '0.0.101']) { + const read = reader(SAMPLE); + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion, readChangelog: read }); + assert.equal(result.sections, null); + assert.equal(result.record, null); + assert.equal(read.calls(), 0); + } +}); + +test('startup with a missing changelog shows nothing, reports the error and records nothing', () => { + const missing = () => { const e = new Error('ENOENT: no such file'); e.code = 'ENOENT'; throw e; }; + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion: '0.0.98', readChangelog: missing }); + assert.equal(result.sections, null); + assert.equal(result.record, null); + assert.match(result.error, /ENOENT/); +}); + +test('startup with a broken changelog shows nothing, reports the error and records nothing', () => { + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion: '0.0.98', readChangelog: reader('## nonsense\n') }); + assert.equal(result.sections, null); + assert.equal(result.record, null); + assert.match(result.error, /malformed heading/); +}); + +test('startup with no section in range records the current version and shows nothing', () => { + const result = whatsNewOnStartup({ currentVersion: '0.0.101', lastSeenVersion: '0.0.100', readChangelog: reader(SAMPLE) }); + assert.equal(result.sections, null); + assert.equal(result.record, '0.0.101'); + assert.equal(result.error, null); +}); + +test('the menu entry gives the current version section only', () => { + const result = whatsNewForVersion({ currentVersion: '0.0.99', readChangelog: reader(SAMPLE) }); + assert.deepEqual(result.sections.map((s) => s.version), ['0.0.99']); + assert.equal(result.error, null); +}); + +test('the menu entry gives nothing and an error when the current version has no section or the file is broken', () => { + const absent = whatsNewForVersion({ currentVersion: '0.0.101', readChangelog: reader(SAMPLE) }); + assert.equal(absent.sections, null); + assert.match(absent.error, /no section for 0\.0\.101/); + const broken = whatsNewForVersion({ currentVersion: '0.0.99', readChangelog: reader('') }); + assert.equal(broken.sections, null); + assert.match(broken.error, /no version section/); +}); + +test('the repository CHANGELOG.md parses, and has a section for the version in package.json', () => { + const text = fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8'); + const { unreleased, versions } = parseChangelog(text); + assert.equal(typeof unreleased, 'string', 'CHANGELOG.md must keep a ## Unreleased section'); + const { version } = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + assert.ok(versions.some((v) => v.version === version), `CHANGELOG.md has no section for ${version}`); + for (let i = 1; i < versions.length; i++) { + assert.equal(compareVersions(versions[i - 1].version, versions[i].version), 1, 'versions must be newest first'); + } +}); + +test('electron-builder packages CHANGELOG.md', () => { + const { build } = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + assert.ok(build.files.includes('CHANGELOG.md'), 'CHANGELOG.md must be in build.files, or the packaged app has no changelog to read'); +}); diff --git a/test/check-changelog.test.js b/test/check-changelog.test.js new file mode 100644 index 00000000..00324366 --- /dev/null +++ b/test/check-changelog.test.js @@ -0,0 +1,228 @@ +// The CI check that a PR changing the app carries a CHANGELOG.md entry — see docs/changelog.md. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawnSync } = require('node:child_process'); + +const { isAppPath, checkChangelog, parseLabels } = require('../scripts/check-changelog'); + +test('app code is what electron-builder ships: root scripts, public/, workers/, the sandbox wrapper', () => { + for (const file of ['main.js', 'preload.js', 'git-changes.js', 'public/app.js', 'public/style.css', + 'public/index.html', 'workers/search-worker.js', 'scripts/claude-sandbox.sh']) { + assert.equal(isAppPath(file), true, file); + } +}); + +test('tests, docs, CI, tooling and nested scripts are not app code', () => { + for (const file of ['test/changelog.test.js', 'docs/releasing.md', 'README.md', '.github/workflows/test.yml', + 'eslint.config.js', 'scripts/run-tests.js', 'package-lock.json', '.ai/shared-guidelines.md', + 'test/fixtures/main.js', 'CHANGELOG.md']) { + assert.equal(isAppPath(file), false, file); + } +}); + +test('a PR changing app code without CHANGELOG.md fails, naming the files', () => { + const result = checkChangelog({ files: ['main.js', 'test/x.test.js'], labels: [] }); + assert.equal(result.ok, false); + assert.match(result.message, /main\.js/); + assert.doesNotMatch(result.message, /x\.test\.js/); + assert.match(result.message, /no-changelog/); +}); + +test('a PR changing app code and CHANGELOG.md passes', () => { + assert.equal(checkChangelog({ files: ['main.js', 'CHANGELOG.md'], labels: [] }).ok, true); +}); + +test('a PR changing app code without CHANGELOG.md passes with the no-changelog label', () => { + assert.equal(checkChangelog({ files: ['public/app.js'], labels: ['bug', 'no-changelog'] }).ok, true); +}); + +test('another label does not waive the entry', () => { + assert.equal(checkChangelog({ files: ['public/app.js'], labels: ['changelog', 'no-changelog-please'] }).ok, false); +}); + +test('a PR that changes no app code passes without an entry', () => { + assert.equal(checkChangelog({ files: ['docs/releasing.md', 'test/a.test.js'], labels: [] }).ok, true); + assert.equal(checkChangelog({ files: [], labels: [] }).ok, true); +}); + +test('labels come from a JSON array of names, or from nothing', () => { + assert.deepEqual(parseLabels('["no-changelog","bug"]'), ['no-changelog', 'bug']); + assert.deepEqual(parseLabels(''), []); + assert.deepEqual(parseLabels(undefined), []); +}); + +test('labels that are not a JSON array of names are an error, not an empty list', () => { + assert.throws(() => parseLabels('no-changelog'), /PR_LABELS/); + assert.throws(() => parseLabels('{"name":"no-changelog"}'), /PR_LABELS/); + assert.throws(() => parseLabels('[1]'), /PR_LABELS/); +}); + +test('the test workflow only invokes the script, with the PR base and head SHAs and the PR to read labels from', () => { + const workflow = fs.readFileSync(path.join(__dirname, '..', '.github', 'workflows', 'test.yml'), 'utf8'); + const start = workflow.indexOf('\n changelog:'); + assert.notEqual(start, -1, 'test.yml must carry a changelog job'); + const job = workflow.slice(start, workflow.indexOf('\n test:', start)); + assert.match(job, /if: github\.event_name == 'pull_request'/); + assert.match(job, /fetch-depth: 0/); + assert.match(job, /GH_TOKEN: \$\{\{ github\.token \}\}/); + const run = job.match(/run: (.*)/)[1]; + assert.equal(run, 'node scripts/check-changelog.js' + + ' --base "${{ github.event.pull_request.base.sha }}"' + + ' --head "${{ github.event.pull_request.head.sha }}"' + + ' --repo "${{ github.repository }}"' + + ' --pr "${{ github.event.pull_request.number }}"'); +}); + +const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-changelog.js'); +const GIT_ENV = { + GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_CONFIG_NOSYSTEM: '1', LC_ALL: 'C', +}; + +function makeRepo(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-changelog-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const inherited = Object.fromEntries(Object.entries(process.env) + .filter(([k]) => !k.startsWith('GIT_') && !k.startsWith('HUSKY'))); + const env = { ...inherited, ...GIT_ENV, HOME: dir }; + const git = (...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd: dir, env, encoding: 'utf8' }).trim(); + const write = (file, text) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text); + }; + const commit = (files, message) => { + for (const [file, text] of Object.entries(files)) write(file, text); + git('add', '-A'); + git('commit', '-qm', message); + return git('rev-parse', 'HEAD'); + }; + git('init', '-q'); + git('symbolic-ref', 'HEAD', 'refs/heads/main'); + const base = commit({ 'main.js': 'a\n', 'docs/a.md': 'a\n', 'CHANGELOG.md': '# Changelog\n' }, 'base'); + git('checkout', '-q', '-b', 'pr'); + return { dir, env, git, commit, base }; +} + +function check(repo, args, extraEnv = {}) { + const r = spawnSync(process.execPath, [SCRIPT, ...args], { cwd: repo.dir, env: { ...repo.env, ...extraEnv }, encoding: 'utf8' }); + return { status: r.status, out: r.stdout + r.stderr }; +} + +test('end to end: an app change without an entry fails and names the file', (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'main.js': 'b\n' }, 'app'); + const r = check(repo, ['--base', repo.base, '--head', head, '--labels', '[]']); + assert.equal(r.status, 1, r.out); + assert.match(r.out, /main\.js/); +}); + +test('end to end: an app change with an entry passes', (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'main.js': 'b\n', 'CHANGELOG.md': '# Changelog\n\n- x\n' }, 'app'); + assert.equal(check(repo, ['--base', repo.base, '--head', head, '--labels', '[]']).status, 0); +}); + +test('end to end: an app change with the no-changelog label passes', (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'main.js': 'b\n' }, 'app'); + assert.equal(check(repo, ['--base', repo.base, '--head', head, '--labels', '["no-changelog"]']).status, 0); +}); + +test('end to end: moving an app file out of the app is an app change, not a rename to hide', (t) => { + const repo = makeRepo(t); + repo.commit({ 'public/foo.js': 'const foo = 1;\n'.repeat(20) }, 'add foo'); + repo.git('checkout', '-q', 'main'); + repo.git('merge', '-q', '--ff-only', 'pr'); + const base = repo.git('rev-parse', 'HEAD'); + repo.git('checkout', '-q', 'pr'); + repo.git('mv', 'public/foo.js', 'docs/foo.js'); + repo.git('commit', '-qm', 'move'); + const head = repo.git('rev-parse', 'HEAD'); + const r = check(repo, ['--base', base, '--head', head, '--labels', '[]']); + assert.equal(r.status, 1, r.out); + assert.match(r.out, /public\/foo\.js/); +}); + +test('end to end: a docs-only change passes', (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'docs/a.md': 'b\n' }, 'docs'); + assert.equal(check(repo, ['--base', repo.base, '--head', head, '--labels', '[]']).status, 0); +}); + +test('end to end: a PR is judged on all its commits, not the last one', (t) => { + const repo = makeRepo(t); + repo.commit({ 'main.js': 'b\n' }, 'app'); + const head = repo.commit({ 'docs/a.md': 'b\n' }, 'docs'); + const r = check(repo, ['--base', repo.base, '--head', head, '--labels', '[]']); + assert.equal(r.status, 1, r.out); + assert.match(r.out, /main\.js/); +}); + +test('end to end: app changes merged in from main are not the PR\'s', (t) => { + const repo = makeRepo(t); + repo.commit({ 'docs/a.md': 'b\n' }, 'docs'); + repo.git('checkout', '-q', 'main'); + const mainTip = repo.commit({ 'main.js': 'from main\n' }, 'main moves on'); + repo.git('checkout', '-q', 'pr'); + repo.git('merge', '-q', '--no-edit', 'main'); + const head = repo.git('rev-parse', 'HEAD'); + const r = check(repo, ['--base', mainTip, '--head', head, '--labels', '[]']); + assert.equal(r.status, 0, r.out); +}); + +test('end to end: app changes on main since the PR branched are not the PR\'s', (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'docs/a.md': 'b\n' }, 'docs'); + repo.git('checkout', '-q', 'main'); + const mainTip = repo.commit({ 'main.js': 'from main\n' }, 'main moves on'); + const r = check(repo, ['--base', mainTip, '--head', head, '--labels', '[]']); + assert.equal(r.status, 0, r.out); +}); + +test('end to end: a base the repository does not have is an error, not a pass', (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'main.js': 'b\n' }, 'app'); + const r = check(repo, ['--base', '0'.repeat(40), '--head', head, '--labels', '[]']); + assert.equal(r.status, 2, r.out); +}); + +test('end to end: missing arguments are an error, not a pass', (t) => { + const repo = makeRepo(t); + for (const args of [[], ['--base', repo.base, '--labels', '[]'], ['--base', repo.base, '--head', repo.base], ['--base']]) { + const r = check(repo, args); + assert.equal(r.status, 2, args.join(' ')); + assert.match(r.out, /^usage: check-changelog\.js/m, 'refused before running git or gh'); + } +}); + +const POSIX = process.platform !== 'win32'; + +function fakeGh(t, repo, output) { + const bin = path.join(repo.dir, '.bin'); + fs.mkdirSync(bin); + const argsFile = path.join(repo.dir, '.gh-args'); + fs.writeFileSync(path.join(bin, 'gh'), `#!/bin/sh\nprintf '%s\\n' "$@" > '${argsFile}'\nprintf '%s\\n' '${output}'\n`, { mode: 0o755 }); + return { env: { PATH: `${bin}${path.delimiter}${process.env.PATH}` }, args: () => fs.readFileSync(argsFile, 'utf8').trim().split('\n') }; +} + +test('end to end: without --labels the labels are read from the PR through gh, at run time', { skip: !POSIX && 'the fake gh is a POSIX shell script' }, (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'main.js': 'b\n' }, 'app'); + const gh = fakeGh(t, repo, '["bug","no-changelog"]'); + const r = check(repo, ['--base', repo.base, '--head', head, '--repo', 'o/r', '--pr', '7'], gh.env); + assert.equal(r.status, 0, r.out); + assert.deepEqual(gh.args(), ['api', 'repos/o/r/issues/7/labels', '--jq', '[.[].name]']); +}); + +test('end to end: labels read through gh without no-changelog do not waive the entry', { skip: !POSIX && 'the fake gh is a POSIX shell script' }, (t) => { + const repo = makeRepo(t); + const head = repo.commit({ 'main.js': 'b\n' }, 'app'); + const gh = fakeGh(t, repo, '["bug"]'); + assert.equal(check(repo, ['--base', repo.base, '--head', head, '--repo', 'o/r', '--pr', '7'], gh.env).status, 1); +}); diff --git a/test/whats-new-wiring.test.js b/test/whats-new-wiring.test.js new file mode 100644 index 00000000..e0edfebf --- /dev/null +++ b/test/whats-new-wiring.test.js @@ -0,0 +1,55 @@ +// Reads main.js and preload.js as TEXT: the What's new glue is still written +// down. The behaviour is exercised in test/changelog.test.js and +// test/whats-new.test.js. See docs/changelog.md. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { SETTING_DEFAULTS } = require('../public/setting-defaults'); + +const ROOT = path.join(__dirname, '..'); +const MAIN = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); +const PRELOAD = fs.readFileSync(path.join(ROOT, 'preload.js'), 'utf8'); + +test('lastSeenVersion has its default in the shared table, and it is "none recorded"', () => { + assert.ok('lastSeenVersion' in SETTING_DEFAULTS); + assert.equal(SETTING_DEFAULTS.lastSeenVersion, null); +}); + +test('main reads CHANGELOG.md from the app directory, so the packaged copy is the one read', () => { + assert.match(MAIN, /path\.join\(__dirname, 'CHANGELOG\.md'\)/); +}); + +test('whether the install predates this launch is read when main loads, before this run writes a setting', () => { + const at = MAIN.indexOf("const INSTALL_PREDATES_LAUNCH = getSetting('global') !== null || isInitialScanComplete();"); + assert.notEqual(at, -1); + assert.ok(at < MAIN.indexOf('function createWindow'), 'it must run at load, not when a window opens'); + assert.ok(at > MAIN.indexOf("} = require('./db');")); +}); + +test('main builds the What\'s new service from the running version, that signal and the shared default', () => { + const at = MAIN.indexOf('const whatsNew = createWhatsNew({'); + assert.notEqual(at, -1); + const args = MAIN.slice(at, MAIN.indexOf('});', at)); + assert.match(args, /currentVersion: app\.getVersion\(\)/); + assert.match(args, /existingInstall: INSTALL_PREDATES_LAUNCH/); + assert.match(args, /lastSeenDefault: SETTING_DEFAULTS\.lastSeenVersion/); + assert.match(args, /getSetting,\s*setSetting,/); + assert.match(args, /\blog,/); +}); + +test('the IPC handlers and the menu entry go through the service', () => { + assert.match(MAIN, /ipcMain\.handle\('whats-new-startup', \(\) => whatsNew\.startup\(\)\);/); + assert.match(MAIN, /ipcMain\.handle\('whats-new-dismissed', \(\) => whatsNew\.dismissed\(\)\);/); + const at = MAIN.indexOf('function showWhatsNewFromMenu()'); + const body = MAIN.slice(at, MAIN.indexOf('\n}\n', at)); + assert.match(body, /const payload = whatsNew\.forMenu\(\);\s*if \(payload\) mainWindow\.webContents\.send\('show-whats-new', payload\);/); +}); + +test('preload exposes the three calls the dialog uses', () => { + assert.match(PRELOAD, /whatsNewStartup: \(\) => ipcRenderer\.invoke\('whats-new-startup'\)/); + assert.match(PRELOAD, /whatsNewDismissed: \(\) => ipcRenderer\.invoke\('whats-new-dismissed'\)/); + assert.match(PRELOAD, /onShowWhatsNew: \(callback\) => \{\s*ipcRenderer\.on\('show-whats-new', \(_event, payload\) => callback\(payload\)\);/); +}); diff --git a/test/whats-new.test.js b/test/whats-new.test.js new file mode 100644 index 00000000..f284f0ff --- /dev/null +++ b/test/whats-new.test.js @@ -0,0 +1,233 @@ +// The What's new dialog and its markdown renderer — see docs/changelog.md. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const { renderChangelogMarkdown, showWhatsNew, initWhatsNew } = require('../public/whats-new'); + +const ROOT = path.join(__dirname, '..'); +const CSS = fs.readFileSync(path.join(ROOT, 'public', 'style.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); +const HTML = fs.readFileSync(path.join(ROOT, 'public', 'index.html'), 'utf8'); + +function makeDom() { + const dom = new JSDOM('', { runScripts: 'outside-only' }); + const utils = path.join(ROOT, 'public', 'utils.js'); + vm.runInContext(fs.readFileSync(utils, 'utf8'), dom.getInternalVMContext(), { filename: utils }); + return { doc: dom.window.document, window: dom.window, escape: dom.window.escapeHtml }; +} + +function fakeApi(startupPayload = null) { + const calls = { dismissed: 0, opened: [] }; + const api = { + whatsNewStartup: async () => startupPayload, + whatsNewDismissed: async () => { calls.dismissed++; }, + openExternal: async (url) => { calls.opened.push(url); }, + onShowWhatsNew: (cb) => { api._show = cb; }, + }; + return { api, calls }; +} + +const PAYLOAD = { + version: '0.0.86', + sections: [ + { version: '0.0.86', date: '2026-10-02', body: '### New\n- Eighty-six. (#2)' }, + { version: '0.0.85', date: '2026-10-01', body: '### Fixed\n- Eighty-five. (#1)' }, + ], +}; + +function render(md) { + return renderChangelogMarkdown(md, makeDom().escape); +} + +test('a third-level heading becomes a group title', () => { + assert.equal(render('### New'), '

New

'); +}); + +test('bullets become one list, and an indented line continues its item', () => { + assert.equal(render('- One.\n Still one.\n- Two.'), '
  • One. Still one.
  • Two.
'); +}); + +test('a blank line ends a list, and plain lines form a paragraph', () => { + assert.equal(render('- One.\n\nA line\nand another.'), '
  • One.

A line and another.

'); +}); + +test('a blank line separates two paragraphs', () => { + assert.equal(render('A.\n\nB.'), '

A.

B.

'); +}); + +test('bold, inline code and https links are rendered', () => { + assert.equal( + render('- **Bold** and `code` and [Releases](https://github.com/devsuitup/switchboard/releases).'), + '', + ); +}); + +test('raw HTML in the changelog is shown as text, never parsed', () => { + const html = render('- & '); + assert.equal(html, '
  • <img src=x onerror="alert(1)"> & <script>alert(2)</script>
'); + const { doc } = makeDom(); + doc.body.innerHTML = html; + assert.equal(doc.querySelector('img, script'), null); +}); + +test('markup inside inline code stays literal and escaped', () => { + assert.equal(render('`**x** `'), '

**x** <b>

'); +}); + +test('a link whose URL tries to leave the attribute is not a link', () => { + const { doc, escape } = makeDom(); + doc.body.innerHTML = renderChangelogMarkdown('[x](https://a.example/"onmouseover="x)', escape); + const a = doc.querySelector('a'); + assert.ok(!a || !a.hasAttribute('onmouseover'), 'the URL broke out of href'); + assert.equal(doc.querySelector('[onmouseover]'), null); +}); + +test('only http and https URLs become links', () => { + assert.equal(render('[x](javascript:alert(1))'), '

[x](javascript:alert(1))

'); + assert.equal(render('[x](file:///etc/passwd)'), '

[x](file:///etc/passwd)

'); +}); + +test('the dialog lists every section given, newest first, each under its version and date', () => { + const { doc, escape } = makeDom(); + const { api } = fakeApi(); + showWhatsNew(doc, api, escape, PAYLOAD); + const overlay = doc.querySelector('.whats-new-overlay'); + assert.ok(overlay); + const dialog = overlay.querySelector('.whats-new-dialog'); + assert.equal(dialog.getAttribute('role'), 'dialog'); + assert.equal(dialog.getAttribute('aria-modal'), 'true'); + assert.deepEqual([...dialog.querySelectorAll('.whats-new-section h3')].map((h) => h.textContent), + ['v0.0.86 — 2026-10-02', 'v0.0.85 — 2026-10-01']); + assert.match(dialog.querySelector('.whats-new-body').innerHTML, /Eighty-six[\s\S]*Eighty-five/); +}); + +test('a section heading is escaped too: the dialog does not trust what crosses the IPC', () => { + const { doc, escape } = makeDom(); + showWhatsNew(doc, fakeApi().api, escape, { version: '1', sections: [ + { version: '', date: 'd', body: '' }, + ] }); + const heading = doc.querySelector('.whats-new-section h3'); + assert.equal(heading.textContent, 'v — d'); + assert.equal(heading.querySelector('img, b'), null); +}); + +test('the close button closes the dialog and records it as seen, once', () => { + const { doc, escape } = makeDom(); + const { api, calls } = fakeApi(); + showWhatsNew(doc, api, escape, PAYLOAD); + const close = doc.querySelector('.whats-new-close'); + assert.equal(close.getAttribute('aria-label'), 'Close'); + close.click(); + assert.equal(doc.querySelector('.whats-new-overlay'), null); + assert.equal(calls.dismissed, 1); +}); + +test('Escape closes the dialog, and a later Escape does nothing more', () => { + const { doc, escape, window } = makeDom(); + const { api, calls } = fakeApi(); + showWhatsNew(doc, api, escape, PAYLOAD); + doc.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.equal(doc.querySelector('.whats-new-overlay'), null); + doc.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.equal(calls.dismissed, 1); +}); + +test('the Escape that closes the dialog does not also reach the element behind it', () => { + const { doc, escape, window } = makeDom(); + const terminal = doc.createElement('textarea'); + doc.body.appendChild(terminal); + let reached = 0; + terminal.addEventListener('keydown', () => { reached++; }); + showWhatsNew(doc, fakeApi().api, escape, PAYLOAD); + terminal.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.equal(doc.querySelector('.whats-new-overlay'), null); + assert.equal(reached, 0); +}); + +test('another key leaves the dialog open', () => { + const { doc, escape, window } = makeDom(); + const { api, calls } = fakeApi(); + showWhatsNew(doc, api, escape, PAYLOAD); + doc.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.ok(doc.querySelector('.whats-new-overlay')); + assert.equal(calls.dismissed, 0); +}); + +test('a click on the backdrop closes the dialog; a click inside it does not', () => { + const { doc, escape } = makeDom(); + const { api, calls } = fakeApi(); + showWhatsNew(doc, api, escape, PAYLOAD); + doc.querySelector('.whats-new-body').click(); + assert.ok(doc.querySelector('.whats-new-overlay')); + doc.querySelector('.whats-new-overlay').click(); + assert.equal(doc.querySelector('.whats-new-overlay'), null); + assert.equal(calls.dismissed, 1); +}); + +test('the dialog takes the focus, so keys do not reach a terminal behind it', () => { + const { doc, escape } = makeDom(); + showWhatsNew(doc, fakeApi().api, escape, PAYLOAD); + assert.equal(doc.activeElement, doc.querySelector('.whats-new-close')); +}); + +test('a link opens in the browser through the main process, not in the window', () => { + const { doc, escape, window } = makeDom(); + const { api, calls } = fakeApi(); + showWhatsNew(doc, api, escape, { version: '0.0.86', sections: [ + { version: '0.0.86', date: '2026-10-02', body: '- See [the release](https://example.com/r).' }, + ] }); + const event = new window.MouseEvent('click', { bubbles: true, cancelable: true }); + doc.querySelector('.whats-new-link').dispatchEvent(event); + assert.equal(event.defaultPrevented, true); + assert.deepEqual(calls.opened, ['https://example.com/r']); + assert.ok(doc.querySelector('.whats-new-overlay'), 'following a link does not close the dialog'); +}); + +test('a second request while the dialog is open does not stack another one', () => { + const { doc, escape } = makeDom(); + const { api } = fakeApi(); + showWhatsNew(doc, api, escape, PAYLOAD); + showWhatsNew(doc, api, escape, PAYLOAD); + assert.equal(doc.querySelectorAll('.whats-new-overlay').length, 1); +}); + +test('on startup the dialog opens when the main process sends sections, and not when it sends nothing', async () => { + const withSections = makeDom(); + await initWhatsNew(withSections.doc, fakeApi(PAYLOAD).api, withSections.escape); + assert.ok(withSections.doc.querySelector('.whats-new-overlay')); + + const empty = makeDom(); + await initWhatsNew(empty.doc, fakeApi(null).api, empty.escape); + assert.equal(empty.doc.querySelector('.whats-new-overlay'), null); +}); + +test('the Help menu entry opens the dialog through the show-whats-new event', async () => { + const { doc, escape } = makeDom(); + const { api } = fakeApi(null); + await initWhatsNew(doc, api, escape); + api._show(PAYLOAD); + assert.ok(doc.querySelector('.whats-new-overlay')); +}); + +test('the dialog body scrolls when long, and the overlay is exempt from the window drag region', () => { + const body = CSS.match(/\.whats-new-body\s*\{([^}]*)\}/); + assert.ok(body, 'style.css must style .whats-new-body'); + assert.match(body[1], /overflow-y:\s*auto/); + const dialog = CSS.match(/\.whats-new-dialog\s*\{([^}]*)\}/); + assert.match(dialog[1], /max-height:/); + const noDrag = CSS.match(/body\.window-frameless :is\(([^)]*)\)\s*\{[^}]*no-drag/); + assert.match(noDrag[1], /\.whats-new-overlay/); +}); + +test('index.html loads whats-new.js after utils.js, which defines escapeHtml', () => { + const utils = HTML.indexOf('