From a65730fbf2371e8f6d77093c9ef89c1613d8fdfe Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Wed, 30 Sep 2026 16:04:38 +0200 Subject: [PATCH 1/4] (changelog): keep a user-facing changelog, and show What's new after an update Nothing told a user what changed when the app updated: the GitHub release notes were the commit subjects, written about the code, and nobody opens them after an automatic update. - CHANGELOG.md at the root: an Unreleased section, then one section per version with New / Changed / Fixed entries on what the user sees. Backfilled for v0.0.80 to v0.0.84 and filled with what main carries since v0.0.84. - docs/changelog.md holds the writing rule; the finish-work list in .ai/shared-guidelines.md asks every behaviour change for its entry. - The release bump dates the Unreleased section, and the publish job writes the release body from that section (scripts/changelog-section.js) instead of the commit subjects, still failing on an empty body. - A changelog job in test.yml fails a PR that changes shipped app code without touching CHANGELOG.md, unless it has the no-changelog label. - A What's new dialog: on the first start after an update it lists every section after the stored lastSeenVersion up to the running version, skipped versions included, and records the version when closed. A fresh install records the version and shows nothing; a missing or unparsable file shows nothing and logs a warning. Help -> What's new reopens the running version's section. CHANGELOG.md is packaged and read main-side. Closes #363 --- .ai/contexts/ipc-bridge.md | 3 +- .ai/contexts/window-frame.md | 4 +- .ai/shared-guidelines.md | 12 +- .claude/commands/release.md | 14 +- .github/workflows/build.yml | 27 +--- .github/workflows/test.yml | 18 +++ CHANGELOG.md | 76 +++++++++++ changelog.js | 91 +++++++++++++ docs/README.md | 1 + docs/changelog.md | 105 +++++++++++++++ docs/releasing.md | 23 +++- docs/window.md | 1 + eslint.config.js | 2 +- main.js | 38 +++++- package.json | 1 + preload.js | 5 + public/index.html | 1 + public/setting-defaults.js | 1 + public/style.css | 107 ++++++++++++++- public/whats-new.js | 107 +++++++++++++++ scripts/changelog-section.js | 27 ++++ scripts/check-changelog.js | 51 ++++++++ test/changelog-section.test.js | 43 ++++++ test/changelog.test.js | 216 ++++++++++++++++++++++++++++++ test/check-changelog.test.js | 70 ++++++++++ test/whats-new-wiring.test.js | 59 +++++++++ test/whats-new.test.js | 233 +++++++++++++++++++++++++++++++++ test/window-frame.test.js | 13 +- window-frame.js | 8 +- 29 files changed, 1310 insertions(+), 47 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 changelog.js create mode 100644 docs/changelog.md create mode 100644 public/whats-new.js create mode 100644 scripts/changelog-section.js create mode 100644 scripts/check-changelog.js create mode 100644 test/changelog-section.test.js create mode 100644 test/changelog.test.js create mode 100644 test/check-changelog.test.js create mode 100644 test/whats-new-wiring.test.js create mode 100644 test/whats-new.test.js 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 2e3dfb2b..8c183fd9 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; @@ -4965,6 +5070,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..9bd914e3 --- /dev/null +++ b/scripts/check-changelog.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// see docs/changelog.md ("The CI check") +'use strict'; + +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; +} + +if (require.main === module) { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { input += chunk; }); + process.stdin.on('end', () => { + const files = input.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const result = checkChangelog({ files, labels: parseLabels(process.env.PR_LABELS) }); + console.log(result.message); + process.exitCode = result.ok ? 0 : 1; + }); +} + +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..57bec5cf --- /dev/null +++ b/test/changelog.test.js @@ -0,0 +1,216 @@ +// 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, +} = 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, readChangelog: read }); + assert.equal(result.sections, null); + assert.equal(result.record, '0.0.100'); + assert.equal(read.calls(), 0); + } +}); + +test('startup with an unreadable stored version records the current one and shows nothing', () => { + const result = whatsNewOnStartup({ currentVersion: '0.0.100', lastSeenVersion: 'garbage', readChangelog: reader(SAMPLE) }); + assert.equal(result.sections, null); + assert.equal(result.record, '0.0.100'); +}); + +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..82fe44f9 --- /dev/null +++ b/test/check-changelog.test.js @@ -0,0 +1,70 @@ +// 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 { 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 runs the check on pull requests, with the PR labels read at run time', () => { + const workflow = fs.readFileSync(path.join(__dirname, '..', '.github', 'workflows', 'test.yml'), 'utf8'); + const job = workflow.slice(workflow.indexOf('\n changelog:')); + assert.notEqual(workflow.indexOf('\n changelog:'), -1, 'test.yml must carry a changelog job'); + assert.match(job, /if: github\.event_name == 'pull_request'/); + assert.match(job, /gh api "repos\/\$\{\{ github\.repository \}\}\/issues\/\$\{\{ github\.event\.pull_request\.number \}\}\/labels"/); + assert.match(job, /node scripts\/check-changelog\.js/); +}); diff --git a/test/whats-new-wiring.test.js b/test/whats-new-wiring.test.js new file mode 100644 index 00000000..3b32be5c --- /dev/null +++ b/test/whats-new-wiring.test.js @@ -0,0 +1,59 @@ +// 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'); + +function handlerBody(channel) { + const at = MAIN.indexOf(`ipcMain.handle('${channel}'`); + assert.notEqual(at, -1, `main.js must handle ${channel}`); + return MAIN.slice(at, MAIN.indexOf('\n});', at)); +} + +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('whats-new-startup decides from the app version and the stored lastSeenVersion, logs an error, records what it is told', () => { + const body = handlerBody('whats-new-startup'); + assert.match(body, /whatsNewOnStartup\(\{\s*currentVersion,\s*lastSeenVersion,\s*readChangelog\s*\}\)/); + assert.match(body, /const currentVersion = app\.getVersion\(\)/); + assert.match(body, /\.lastSeenVersion \?\? SETTING_DEFAULTS\.lastSeenVersion/); + assert.match(body, /if \(error\) log\.warn\(/); + assert.match(body, /if \(record\) recordLastSeenVersion\(record\)/); +}); + +test('whats-new-dismissed records the running version, whatever the renderer sends', () => { + const body = handlerBody('whats-new-dismissed'); + assert.match(body, /recordLastSeenVersion\(app\.getVersion\(\)\)/); + assert.doesNotMatch(body, /\(_event,/); +}); + +test('the menu entry sends the current section to the renderer, and only logs when there is none', () => { + const at = MAIN.indexOf('function showWhatsNewFromMenu()'); + assert.notEqual(at, -1); + const body = MAIN.slice(at, MAIN.indexOf('\n}\n', at)); + assert.match(body, /whatsNewForVersion\(\{\s*currentVersion,\s*readChangelog\s*\}\)/); + assert.match(body, /log\.warn\(/); + assert.match(body, /webContents\.send\('show-whats-new'/); +}); + +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.'), ''); +}); + +test('a blank line ends a list, and plain lines form a paragraph', () => { + assert.equal(render('- One.\n\nA line\nand another.'), '

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, ''); + 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('