feat(config+ux): batch #5 — source maps, integration script, copy, redownload options

Clean config/copy items (Phase 7):
- L170: build.sourcemap 'hidden' on main/preload/renderer — emit .map for offline
  crash symbolication (of the CC8 logger's raw stacks) without exposing them.
- L34: add `test:integration` npm script (AEROFETCH_REAL_DOWNLOAD=1) for the opt-in
  real-download suite; stays out of `npm test` (needs network + live yt-dlp).
- L87: HistoryEntry carries the original `options`; redownload replays them so a
  re-download reproduces the same post-processing instead of the current defaults.
- L96: folder "Browse" buttons -> "Browse…" (they open the OS picker).
- L97: PO-token placeholder "(none)" -> "Optional -- paste a token" (consistent).
- Ticked already-resolved: L99 (M28 aria-label), L113 (UX2 tooltip=aria-label),
  L95 (usage follows verb/modifier rule), L172 (skipLibCheck accepted).

typecheck + 268 tests + eslint + prettier green (touched files LF/clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:06:06 -04:00
parent cc4fb132e4
commit e6b4f343e0
8 changed files with 61 additions and 22 deletions
+38 -16
View File
@@ -577,9 +577,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
*Round 3 (2026-06-29) — tests, build metadata, error copy, edge cases:* *Round 3 (2026-06-29) — tests, build metadata, error copy, edge cases:*
- [ ] **L34 — Integration test off by default.** `real-download.integration.test.ts` is - [x] **L34 — Integration test off by default.** `real-download.integration.test.ts` is
`describe.skipIf(!RUN)`; `npm test` never exercises a real download, so there's no automated `describe.skipIf(!RUN)`; `npm test` never exercises a real download, so there's no automated
regression for the actual yt-dlp argv path (only the pure builder). regression for the actual yt-dlp argv path (only the pure builder). *Fixed: a `test:integration` npm
script sets `AEROFETCH_REAL_DOWNLOAD=1` and runs the suite, making the opt-in real-download regression
explicit + discoverable. It stays out of the default `npm test` on purpose — it needs network + a live
yt-dlp, so it can't run in an unattended/CI pass.*
- [x] **L35 — `isValidMediaItem` has no test** — the one persisted-JSON validator of six with no spec. - [x] **L35 — `isValidMediaItem` has no test** — the one persisted-JSON validator of six with no spec.
- [x] **L36 — `compareVersions` differing-length components untested** (e.g. `1.2` vs `1.2.0`). - [x] **L36 — `compareVersions` differing-length components untested** (e.g. `1.2` vs `1.2.0`).
- [x] **L37 — `download.ts` formatters untested** (`parseProgress`/`fmtBytes`/`fmtEta`, incl. the `NA` paths). - [x] **L37 — `download.ts` formatters untested** (`parseProgress`/`fmtBytes`/`fmtEta`, incl. the `NA` paths).
@@ -684,8 +687,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
(not gitignored). (not gitignored).
- [x] **L86 — History select-all + filter change deletes hidden rows.** `selected` persists across a - [x] **L86 — History select-all + filter change deletes hidden rows.** `selected` persists across a
filter change, so "Delete selected" can remove entries no longer visible (surprising). filter change, so "Delete selected" can remove entries no longer visible (surprising).
- [ ] **L87 — History re-download ignores original options.** `redownload` re-queues url/kind/quality - [x] **L87 — History re-download ignores original options.** `redownload` re-queues url/kind/quality
only; the post-processing `DownloadOptions` used originally are lost (current defaults apply). only; the post-processing `DownloadOptions` used originally are lost (current defaults apply). *Fixed:
`HistoryEntry` now carries the original `options` (recorded on completion in `recordCompletion`), and
`redownload` replays them via `AddOptions.options` — so a re-download reproduces the same subtitles /
SponsorBlock / container / codec instead of the current global defaults. Entries recorded before this
(no stored options) fall back to the defaults as before.*
- [x] **L88 — `applyEvent('progress')` can promote a `queued` item to `downloading`** outside - [x] **L88 — `applyEvent('progress')` can promote a `queued` item to `downloading`** outside
`pump()` — a latent concurrency edge if a progress event ever arrives before/without the launch transition. `pump()` — a latent concurrency edge if a progress event ever arrives before/without the launch transition.
- [x] **L89 — Inconsistent release artifacts.** Only `0.4.1` carries `.sha256` files (hand-made) and - [x] **L89 — Inconsistent release artifacts.** Only `0.4.1` carries `.sha256` files (hand-made) and
@@ -712,14 +719,22 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
items-ref memo `queueSummaryOf` in [queueStats.ts](src/renderer/src/store/queueStats.ts) — the store hands items-ref memo `queueSummaryOf` in [queueStats.ts](src/renderer/src/store/queueStats.ts) — the store hands
out a new items array per change, so both App's taskbar subscription and DownloadsView's render summarize out a new items array per change, so both App's taskbar subscription and DownloadsView's render summarize
it once per change (the second caller hits the cache) instead of twice (also closes PERF4).* it once per change (the second caller hits the cache) instead of twice (also closes PERF4).*
- [ ] **L95 — "Sign in" hyphenation varies** in the Cookies card ("Sign in…", "Site to sign in to", "Sign-in window", "Signing in…"). - [x] **L95 — "Sign in" hyphenation varies** in the Cookies card ("Sign in…", "Site to sign in to", "Sign-in window", "Signing in…").
- [ ] **L96 — Ellipsis-on-dialog-buttons inconsistent.** "Export backup…/Import backup…/Sign in…" use *Reviewed: the usages already follow standard English — the verb is two words ("Sign in…", "Signing in…",
"sign in to") and only the modifier is hyphenated ("Sign-in window"). Consistent by that rule; no change needed.*
- [x] **L96 — Ellipsis-on-dialog-buttons inconsistent.** "Export backup…/Import backup…/Sign in…" use
`…` (opens a dialog), but "Browse" / "Check for updates" / "Update now" don't. Adopt the `…` = opens-a-dialog convention. `…` (opens a dialog), but "Browse" / "Check for updates" / "Update now" don't. Adopt the `…` = opens-a-dialog convention.
- [ ] **L97 — Advanced-field placeholders inconsistent.** Adjacent fields use an example (`web_safari`), *Fixed: the folder "Browse" buttons (they open the OS picker) are now "Browse…". "Check for updates" /
a literal `(none)`, and `Optional — Gitea access token`. Pick one placeholder style. "Update now" open no dialog (inline result / background download), so they correctly keep no ellipsis.*
- [x] **L97 — Advanced-field placeholders inconsistent.** Adjacent fields use an example (`web_safari`),
a literal `(none)`, and `Optional — Gitea access token`. Pick one placeholder style. *Fixed: standardized
on "an example value where the field has a representative one (`web_safari`, `2M`, `socks5://…`),
`Optional -- <what>` for opaque optional fields" — the PO-token's uninformative `(none)` is now
`Optional -- paste a token`, matching the update-token field.*
- [x] **L98 — "Embed chapters" has no hint** while every sibling toggle in DownloadOptionsForm does. - [x] **L98 — "Embed chapters" has no hint** while every sibling toggle in DownloadOptionsForm does.
- [ ] **L99 — URL input has an `id` but no label.** `input={{ id: 'aerofetch-url' }}` with no - [x] **L99 — URL input has an `id` but no label.** `input={{ id: 'aerofetch-url' }}` with no
`<label for>`/`aria-label` (placeholder only) — see M28. `<label for>`/`aria-label` (placeholder only) — see M28. *Resolved by M28: the URL input already carries
`aria-label="Video or playlist URL"`, so it has an accessible name — the placeholder isn't the only affordance.*
- [ ] **L100 — Inconsistent elevation.** DownloadBar (`shadow4`) and CommandPalette (`shadow28`) float; - [ ] **L100 — Inconsistent elevation.** DownloadBar (`shadow4`) and CommandPalette (`shadow28`) float;
every other card (Settings, QueueItem, History, Library, Templates) is flat (border-only). Define an elevation scale. every other card (Settings, QueueItem, History, Library, Templates) is flat (border-only). Define an elevation scale.
- [ ] **L101 — No z-index scale.** CommandPalette and Hint both hardcode `zIndex: 1000`. - [ ] **L101 — No z-index scale.** CommandPalette and Hint both hardcode `zIndex: 1000`.
@@ -743,8 +758,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L111 — Two helper-text mechanisms** — Fluent `Field` `hint` vs manual `<Caption1 className={hint}>`; SettingsView uses both. - [ ] **L111 — Two helper-text mechanisms** — Fluent `Field` `hint` vs manual `<Caption1 className={hint}>`; SettingsView uses both.
- [ ] **L112 — `Switch` labeling inconsistent.** Settings/DownloadOptionsForm show "On"/"Off" text; - [ ] **L112 — `Switch` labeling inconsistent.** Settings/DownloadOptionsForm show "On"/"Off" text;
Library switches use `aria-label` only (no visible state text). Library switches use `aria-label` only (no visible state text).
- [ ] **L113 — Tooltip ≠ aria-label.** The Fetch button's Hint says "Fetch formats / playlist" but its - [x] **L113 — Tooltip ≠ aria-label.** The Fetch button's Hint says "Fetch formats / playlist" but its
`aria-label` says "Fetch formats or playlist" (DownloadBar). `aria-label` says "Fetch formats or playlist" (DownloadBar). *Resolved by UX2: the Fetch button's Hint
tooltip and `aria-label` are now the same string ("Preview available formats (optional)"), so what a
sighted user sees on hover and what a screen reader announces match.*
- [ ] **L114 — Row titles use different components**`Text` (QueueItem/History/Templates) vs `span` (Library) vs `Caption1` elsewhere. - [ ] **L114 — Row titles use different components**`Text` (QueueItem/History/Templates) vs `span` (Library) vs `Caption1` elsewhere.
- [ ] **L115 — Equivalent row actions labeled inconsistently** — "Re-index" is icon+text; the - [ ] **L115 — Equivalent row actions labeled inconsistently** — "Re-index" is icon+text; the
comparable "Retry"/"Remove"/"Re-download" are icon-only. comparable "Retry"/"Remove"/"Re-download" are icon-only.
@@ -907,15 +924,20 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
would surface them at build time. would surface them at build time.
- [x] **L169 — `noFallthroughCasesInSwitch` is off.** The large `setSettings` switch and `applyEvent` - [x] **L169 — `noFallthroughCasesInSwitch` is off.** The large `setSettings` switch and `applyEvent`
switch aren't fallthrough-guarded; a missing `break`/`return` wouldn't be caught. switch aren't fallthrough-guarded; a missing `break`/`return` wouldn't be caught.
- [ ] **L170 — No source maps in production.** `sourceMap: false` (toolkit) and no `build.sourcemap` in - [x] **L170 — No source maps in production.** `sourceMap: false` (toolkit) and no `build.sourcemap` in
`electron.vite.config.ts`, so field crash stack traces are unmapped — combined with no logging (CC8/M29), `electron.vite.config.ts`, so field crash stack traces are unmapped — combined with no logging (CC8/M29),
diagnosing a user-reported crash is very hard. Consider hidden/external source maps. diagnosing a user-reported crash is very hard. Consider hidden/external source maps. *Fixed:
`build.sourcemap: 'hidden'` on all three bundles (main / preload / renderer) in
[electron.vite.config.ts](electron.vite.config.ts) — `.map` files are emitted for offline symbolication
of the raw stacks the logger now records (CC8), with no `sourceMappingURL` reference in the shipped bundle.*
- [ ] **L171 — `main.tsx` mock drift.** The preview mock's version strings disagree - [ ] **L171 — `main.tsx` mock drift.** The preview mock's version strings disagree
(`getAppVersion`→'0.4.0-preview' vs `checkForAppUpdate.currentVersion`→'0.4.0'), and its `setSettings` (`getAppVersion`→'0.4.0-preview' vs `checkForAppUpdate.currentVersion`→'0.4.0'), and its `setSettings`
skips the real validation/sanitization — so the browser preview can accept settings the app rejects, skips the real validation/sanitization — so the browser preview can accept settings the app rejects,
masking M34-class issues during design. masking M34-class issues during design.
- [ ] **L172 — `skipLibCheck: true`** (toolkit) hides type errors in dependency `.d.ts` files — standard - [x] **L172 — `skipLibCheck: true`** (toolkit) hides type errors in dependency `.d.ts` files — standard
practice, low risk, noted for awareness (a dep type regression won't fail typecheck). practice, low risk, noted for awareness (a dep type regression won't fail typecheck). *Acknowledged —
kept as-is (it IS standard practice and speeds typecheck); recorded so the trade-off (a dependency
`.d.ts` regression can pass typecheck silently) is a known, accepted decision rather than an oversight.*
--- ---
+8 -2
View File
@@ -9,7 +9,11 @@ export default defineConfig({
alias: { alias: {
'@shared': resolve('src/shared') '@shared': resolve('src/shared')
} }
} },
// Hidden source maps: emit .map files so a field crash stack (the logger writes
// raw stacks, CC8) can be symbolicated offline, without referencing/exposing them
// in the shipped bundle (L170).
build: { sourcemap: 'hidden' }
}, },
preload: { preload: {
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
@@ -21,6 +25,7 @@ export default defineConfig({
// Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling // Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling
// the sandbox (webPreferences.sandbox) is part of the security hardening. // the sandbox (webPreferences.sandbox) is part of the security hardening.
build: { build: {
sourcemap: 'hidden',
rollupOptions: { rollupOptions: {
output: { output: {
format: 'cjs', format: 'cjs',
@@ -36,6 +41,7 @@ export default defineConfig({
'@shared': resolve('src/shared') '@shared': resolve('src/shared')
} }
}, },
plugins: [react()] plugins: [react()],
build: { sourcemap: 'hidden' }
} }
}) })
+2 -1
View File
@@ -29,7 +29,8 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
"test": "vitest run" "test": "vitest run",
"test:integration": "set AEROFETCH_REAL_DOWNLOAD=1&& vitest run test/real-download.integration.test.ts"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
@@ -196,6 +196,9 @@ export function HistoryView(): React.JSX.Element {
title: h.title, title: h.title,
channel: h.channel, channel: h.channel,
thumbnail: h.thumbnail, thumbnail: h.thumbnail,
// Replay the original post-processing options (L87); undefined falls back to
// the global defaults for entries recorded before this was carried.
options: h.options,
format: h.formatId format: h.formatId
? { id: h.formatId, hasAudio: h.formatHasAudio ?? false, label: h.quality } ? { id: h.formatId, hasAudio: h.formatHasAudio ?? false, label: h.quality }
: undefined : undefined
@@ -71,7 +71,7 @@ export function DownloadsCard(): React.JSX.Element {
onChange={(_, d) => update({ videoDir: d.value })} onChange={(_, d) => update({ videoDir: d.value })}
/> />
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}> <Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
Browse Browse
</Button> </Button>
{videoDir && ( {videoDir && (
<Button appearance="subtle" onClick={() => clearDir('videoDir')}> <Button appearance="subtle" onClick={() => clearDir('videoDir')}>
@@ -94,7 +94,7 @@ export function DownloadsCard(): React.JSX.Element {
onChange={(_, d) => update({ audioDir: d.value })} onChange={(_, d) => update({ audioDir: d.value })}
/> />
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}> <Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Browse Browse
</Button> </Button>
{audioDir && ( {audioDir && (
<Button appearance="subtle" onClick={() => clearDir('audioDir')}> <Button appearance="subtle" onClick={() => clearDir('audioDir')}>
@@ -86,7 +86,7 @@ export function NetworkCard(): React.JSX.Element {
type="password" type="password"
style={{ flexGrow: 1 }} style={{ flexGrow: 1 }}
value={youtubePoToken} value={youtubePoToken}
placeholder="(none)" placeholder="Optional -- paste a token"
onChange={(_, d) => { onChange={(_, d) => {
update({ youtubePoToken: d.value }) update({ youtubePoToken: d.value })
setPotHint(null) setPotHint(null)
+3
View File
@@ -44,6 +44,9 @@ function recordCompletion(item: DownloadItem): void {
filePath: item.filePath, filePath: item.filePath,
sizeLabel: item.sizeLabel, sizeLabel: item.sizeLabel,
thumbnail: item.thumbnail, thumbnail: item.thumbnail,
// Carry the post-processing options so a re-download reproduces the same
// output rather than the current global defaults (L87).
options: item.options,
completedAt: Date.now() completedAt: Date.now()
}) })
} }
+4
View File
@@ -731,6 +731,10 @@ export interface HistoryEntry {
filePath?: string filePath?: string
sizeLabel?: string sizeLabel?: string
thumbnail?: string thumbnail?: string
/** The per-download post-processing options used originally, carried so a
* re-download reproduces the same output (subtitles, SponsorBlock, container,
* codec, …) instead of silently falling back to the current global defaults (L87). */
options?: DownloadOptions
/** epoch milliseconds */ /** epoch milliseconds */
completedAt: number completedAt: number
} }