feat(commands): whole-history dedupe + switchable, capped history
历史命令去重从「只跟最新一条比」改为「全历史去重」:重敲任意一条已有命令 时把原条目提到最前(刷新 lastUsedAt)而不是新插一条,历史里每个命令只出现一次。 新增两个设置(设置 → 终端): - 记录命令历史:关闭后不再记录新命令;已有历史保留,不清空 - 历史条数上限:默认 100,可配 1..500;写入按上限截断,读取也按当前 上限截断(调低立即生效) 顺修一个暴露的既有 bug:logWrite 用 async appendFile,两次快速追加会 乱序落盘(line two 先于 line one)。改为按文件串行化追加。 测试:tests/commands-store.mjs 全历史去重 / 提到最前 / 上限生效 / 开关关闭不记录 / 关闭保留已有 / 恢复记录 / 越界上限夹紧,全部通过。
This commit is contained in:
1 parent
beeb32a76b
commit
277d8bb117
5 files changed
+254
-29
No files matched your search
+78
-15
@@ -6,10 +6,11 @@
|
||||
* session log write/read/index behavior against a temp userData dir.
|
||||
*
|
||||
* Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \
|
||||
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs
|
||||
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs \
|
||||
* --alias:@shared=./src/shared
|
||||
* Run: node tests/commands-store.mjs (must exit 0)
|
||||
*/
|
||||
import { mkdtempSync, readFileSync } from 'fs'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'module'
|
||||
@@ -25,16 +26,23 @@ const ok = (cond, msg) => {
|
||||
}
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) -------
|
||||
// ---- 1. Temp userData must exist before the bundle loads ------------------------
|
||||
// The settings store resolves its own path via app.getPath('userData'). The stub
|
||||
// is bundled *into* the module under test, so requiring it here gives a different
|
||||
// instance — `__setUserData` would not reach the bundle. The env var is read when
|
||||
// the stub loads, so it has to be set before the bundle is required below.
|
||||
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
|
||||
process.env.OT_STUB_USERDATA = userData
|
||||
|
||||
// ---- 1b. Bundle the store once (assumes tests/.commands-store.cjs exists) ------
|
||||
let commandsMod
|
||||
try {
|
||||
commandsMod = require_('./.commands-store.cjs')
|
||||
} catch {
|
||||
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs')
|
||||
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs --alias:@shared=./src/shared')
|
||||
}
|
||||
|
||||
// ---- 2. Temp userData; capture openDir target ----------------------------------
|
||||
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
|
||||
// ---- 2. Store over the temp userData; capture openDir target -------------------
|
||||
let openedPath = null
|
||||
const store = new commandsMod.CommandsStore(userData, async (p) => {
|
||||
openedPath = p
|
||||
@@ -53,23 +61,78 @@ store.recordCommand('ps aux')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 3, 're-run of newest command is deduped (no new entry)')
|
||||
|
||||
// Re-run an OLDER command -> it is NOT the newest, so it becomes a fresh front
|
||||
// entry (spec dedupes only against the newest entry) with blank-recency guard.
|
||||
// Re-run an OLDER command -> dedupe is over the whole history, so it moves the
|
||||
// existing entry to the front instead of adding a copy.
|
||||
store.recordCommand('echo hello')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 4, 'older command re-run is a new front entry (dedupe is newest-only)')
|
||||
ok(hist[0].command === 'echo hello', 'older re-run lands at front')
|
||||
ok(hist.length === 3, 'older command re-run does NOT add a copy (whole-history dedupe)')
|
||||
ok(hist[0].command === 'echo hello', 'older re-run is hoisted to the front')
|
||||
// The hoisted entry keeps its original createdAt (it is the same command, not a new one).
|
||||
ok(new Set(hist.map((h) => h.command)).size === 3, 'history holds 3 distinct commands')
|
||||
|
||||
// Cap: fill until well above 500 keeps only the newest 500.
|
||||
for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`)
|
||||
// A command used long ago stays single even after many others in between.
|
||||
for (let i = 0; i < 20; i++) store.recordCommand(`noise-${i}`)
|
||||
store.recordCommand('ls -la')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 500, `history capped at 500 (got ${hist.length})`)
|
||||
ok(hist[0].command === 'cmd-519', 'newest filler at front')
|
||||
ok(hist.filter((h) => h.command === 'ls -la').length === 1, 'a command seen 20 entries ago is not duplicated')
|
||||
ok(hist[0].command === 'ls -la', 'and it is hoisted to the front')
|
||||
|
||||
// Clear.
|
||||
// Clear before the cap test so the assertions below are exact.
|
||||
store.clearHistory()
|
||||
ok(store.listHistory().length === 0, 'clearHistory empties history')
|
||||
|
||||
// ---- 3b. History limit + the recording switch -----------------------------------
|
||||
const writeSettings = (terminal) =>
|
||||
writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal, system: {} }), 'utf8')
|
||||
const DEFAULT_LIMIT = 100
|
||||
|
||||
// The bundled default raises past the old hard cap of 500 only when configured;
|
||||
// out of the box the list is trimmed to the default limit.
|
||||
for (let i = 0; i < 150; i++) store.recordCommand(`filler-${i}`)
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === DEFAULT_LIMIT, `history trimmed to the default limit of ${DEFAULT_LIMIT} (got ${hist.length})`)
|
||||
ok(hist[0].command === 'filler-149', 'newest filler at front')
|
||||
|
||||
// Lower the limit -> takes effect immediately, without needing a new command.
|
||||
writeSettings({ historyLimit: 20 })
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 20, `lowered limit applies on read (got ${hist.length})`)
|
||||
|
||||
// Raise it -> entries that survived the *disk* cap (the configured limit at write
|
||||
// time) become visible again, up to the new limit. Nothing was resurrected from
|
||||
// beyond the write-time cap; the write cap is the real bound on disk growth.
|
||||
writeSettings({ historyLimit: 50 })
|
||||
store.recordCommand('after-raise')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 50, `raising the limit reveals still-stored entries (got ${hist.length})`)
|
||||
ok(hist[0].command === 'after-raise', 'new command recorded after raising the limit')
|
||||
|
||||
// Nonsense / out-of-range limits fall back to something sane instead of throwing.
|
||||
writeSettings({ historyLimit: 0 })
|
||||
ok(store.listHistory().length === 1, 'limit 0 clamps to 1 rather than emptying history')
|
||||
writeSettings({ historyLimit: 99999 })
|
||||
ok(store.listHistory().length === 50, 'absurd limit is clamped to the hard ceiling')
|
||||
|
||||
// The switch: recording stops, and existing history is preserved (not cleared).
|
||||
writeSettings({ historyLimit: 50, historyEnabled: false })
|
||||
const before = store.listHistory()
|
||||
store.recordCommand('must-not-be-recorded')
|
||||
store.recordCommand('must-not-be-recorded')
|
||||
hist = store.listHistory()
|
||||
ok(!hist.some((h) => h.command === 'must-not-be-recorded'), 'historyEnabled=false records nothing')
|
||||
ok(hist.length === before.length, 'turning the switch off keeps existing history intact')
|
||||
|
||||
// Turning it back on resumes recording.
|
||||
writeSettings({ historyLimit: 50, historyEnabled: true })
|
||||
store.recordCommand('recorded-again')
|
||||
ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording')
|
||||
|
||||
// Default (keys absent from settings.json) records, matching a fresh install.
|
||||
writeSettings({})
|
||||
store.recordCommand('default-on')
|
||||
ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording')
|
||||
store.clearHistory()
|
||||
|
||||
// ---- 4. Library CRUD + ordering ------------------------------------------------
|
||||
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
|
||||
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
|
||||
|
||||
Reference in new issue
Block a user