test(main): cover updater fallback, ipc sender guard, log sanitizer, sftp timeouts

- updater-fallback.mjs (82 assertions): GitHub probe fallback to Gitea,
  timeout budgets, in-flight check never stuck in 'checking'; updater.ts
  gains a setUpdateTimeouts test seam, electron-updater aliased to a stub
- ipc-guard.mjs (43): trusted-frame guard exercised through real
  registerIpc handlers with forged senderFrames; electron-stub now records
  registrations via globalThis so bundle and test share one instance
- log-sanitizer.mjs (71): CSI/OSC/charset state machine, alt-screen fold,
  byte-split fuzz equal to whole-chunk output; fixes a wrong comment
- sftp-timeout.mjs (39): per-op timeouts (metadata 30s, transfer chunk 60s,
  open 10s) evict half-dead channels with one retry, slow-but-progressing
  transfers untouched, late rejections never unhandled
- reservedAccelerators.ts: single pure isReservedAccelerator shared by the
  settings recorder and applyGlobalShortcut (the two tables had drifted —
  main now also refuses Ctrl+=/-/0/PgUp/PgDn legacy values); 56 assertions
- ssh-loopback.mjs loads the real ssh.ts via a bundle (50 assertions):
  TOFU pinning, fail-closed stores, auth gate, connect budget

Offline suite grows 13 -> 17. Renderer test framework evaluated: not
introducing vitest/jsdom; pure logic keeps being extracted and tested
through the existing bundle harness.
This commit is contained in:
Bill committed 2026-10-07 22:57:16 +08:00
1 parent 5ff311ea0f
commit a0be827650
21 files changed
+2370 -174

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
/**
* Reserved-accelerator self-test (reserved-accelerators.mjs).
*
* src/shared/reservedAccelerators.ts is the single table two guards read:
* - the settings recorder refuses to SAVE such a chord (SettingsTabs.tsx);
* - `applyGlobalShortcut` refuses to REGISTER one (main/globalShortcuts.ts).
* They used to be two independent tables with two spellings of the same rule,
* and only the renderer's had a test. What is pinned here:
* - the in-app chords: Ctrl+=/-/0 (font size) and Ctrl+PgUp/PgDn (tab cycle),
* under ANY extra modifiers — the in-app handlers ignore Shift/Alt, so a
* global registration of Ctrl+Shift+= would shadow them;
* - Ctrl+L, the panic lock, in every spelling a user or an older build can
* produce ('ctrl+l', 'Ctrl+L', 'Control+L', 'CommandOrControl+L');
* - everything else stays registrable, so the guard cannot grow into a
* blanket refusal.
*
* Build: node tests/build-bundles.cjs
* Run: node tests/reserved-accelerators.mjs (must exit 0)
*/
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { isReservedAccelerator, acceleratorParts } = require('./.reserved-accelerators.cjs')
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const reserved = [
// --- the panic lock, every spelling ---------------------------------------
'Ctrl+L',
'ctrl+l',
'CTRL+L',
'Control+L',
'control+l',
'CommandOrControl+L',
'CmdOrCtrl+L',
'CommandOrCtrl+L',
'Ctrl + L', // Electron tolerates surrounding whitespace
// --- font size (Ctrl = / - / 0) under extra modifiers ---------------------
'Control+=',
'Control+-',
'Control+0',
'Control+Shift+=',
'Control+Shift+-',
'Control+Shift+0',
'Control+Alt+=',
'Control+Alt+Shift+0',
'ctrl+0',
// --- tab cycling (Ctrl+PgUp/PgDn) ----------------------------------------
'Control+PageUp',
'Control+PageDown',
'Control+Shift+PageUp',
'Control+Alt+PageDown',
'ctrl+pageup'
]
const allowed = [
// Not the panic chord: extra/missing modifiers change the meaning on purpose.
'Alt+L',
'Shift+L',
'Super+L',
'Control+Shift+L',
'Control+Alt+L',
'Control+Meta+L',
// The font/tab keys WITHOUT Control belong to whatever is focused.
'=',
'-',
'0',
'Shift+=',
'Alt+=',
'PageUp',
'PageDown',
'Shift+PageUp',
// Other Control chords are free (they do not collide with an in-app binding).
'Control+R',
'Control+Shift+I',
'Control+1',
'Control+PageHome',
'Control+F5',
'Alt+Control+P',
// Standalone function keys.
'F5',
'F12',
'Control+F12'
]
console.log('reserved (must be refused by both guards)')
for (const a of reserved) ok(isReservedAccelerator(a), `reserved: ${JSON.stringify(a)}`)
console.log('allowed (must stay registrable)')
for (const a of allowed) ok(!isReservedAccelerator(a), `allowed: ${JSON.stringify(a)}`)
console.log('modifier aliases fold to one canonical form')
ok(acceleratorParts('Ctrl+L').join('+') === 'control+l', "'Ctrl' folds to 'control'")
ok(acceleratorParts('CommandOrControl+L').join('+') === 'control+l', "'CommandOrControl' folds to 'control' (Windows-only app)")
ok(acceleratorParts('CmdOrCtrl+L').join('+') === 'control+l', "'CmdOrCtrl' folds to 'control'")
ok(acceleratorParts('Ctrl+Shift+P').join('+') === 'control+shift+p', 'Shift is folded to lower case and kept')
ok(acceleratorParts('Super+L')[0] === 'super', 'Super is preserved (not an alias of Control)')
console.log('degenerate input does not throw and grants nothing')
for (const a of ['', ' ', '+', 'Control+', '+L', 'nonsense']) {
let threw = false
let verdict
try {
verdict = isReservedAccelerator(a)
} catch {
threw = true
}
ok(!threw && verdict === false, `junk input refused without throwing: ${JSON.stringify(a)}`)
}
if (failed > 0) {
console.error(`\n[reserved-accelerators] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log(`\n[reserved-accelerators] ALL CHECKS PASSED (${reserved.length + allowed.length + 4 + 6} assertions)`)