688 lines
23 KiB
TypeScript
688 lines
23 KiB
TypeScript
/**
|
|
* Tests for electron/profile-migration.ts — pure migration-decision helpers for
|
|
* the active-profile.json first-boot seeding.
|
|
*
|
|
* Run with: `vitest run` (wired via the `electronNative` project in
|
|
* `apps/desktop/vitest.config.ts`, which discovers tests under `electron/`).
|
|
*
|
|
* These tests cover the four axes the maintainer explicitly required:
|
|
* 1. precedence — legacy > single-running-gateway > state.db heuristic
|
|
* 2. stale PID rejection — recycled PID must not pass as a running gateway
|
|
* 3. fallback behavior — single-profile installs and missing files are no-ops
|
|
* 4. remote-boot path — verified by code review (the migration is moved to the
|
|
* top of startHermes() in main.ts, before primaryProfileKey() is read); the
|
|
* pure decision logic that the function relies on is covered below.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
import type { Dirent } from 'node:fs'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import {
|
|
decideMigration,
|
|
findRunningGatewayProfiles,
|
|
listProfileDirs,
|
|
migrateActiveProfileIfMissing,
|
|
PROFILE_SCORE_MIN_SIZE_BYTES,
|
|
profileGatewayPidPath,
|
|
profileStateDbPath,
|
|
readExistingPreference,
|
|
readLegacyActiveProfile,
|
|
scoreStateDb,
|
|
withDefaultCandidate
|
|
} from './profile-migration'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Mirrors the production PROFILE_NAME_RE in main.ts (regex matches "default";
|
|
// callers like readLegacyActiveProfile must reject "default" explicitly).
|
|
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
|
|
// Production-shaped validator: regex matches, but 'default' is excluded because
|
|
// it's the implicit fallback, never a user-chosen CLI value.
|
|
const isValidProfileName = (n: string) => n !== 'default' && PROFILE_NAME_RE.test(n)
|
|
|
|
const NOW = 1_700_000_000_000
|
|
|
|
type FileFixture = { content?: string; size?: number; mtime?: number; dir?: boolean }
|
|
|
|
function makeFs(files: Record<string, FileFixture>) {
|
|
const map = new Map(Object.entries(files))
|
|
|
|
const existsSync = (p: string) => map.has(p)
|
|
|
|
const readFileSync = (p: string, _enc: 'utf8') => {
|
|
const e = map.get(p)
|
|
|
|
if (!e || e.content === undefined) {
|
|
throw new Error(`ENOENT: ${p}`)
|
|
}
|
|
|
|
return e.content
|
|
}
|
|
|
|
const statSync = (p: string) => {
|
|
const e = map.get(p)
|
|
|
|
if (!e || e.size === undefined) {
|
|
throw new Error(`ENOENT: ${p}`)
|
|
}
|
|
|
|
return { size: e.size, mtimeMs: e.mtime ?? NOW - 86_400_000 }
|
|
}
|
|
|
|
const readdirSync = (p: string, _options?: { withFileTypes?: boolean }): Dirent[] => {
|
|
const names = new Set<string>()
|
|
|
|
// 1. Explicit dir markers at this exact level.
|
|
for (const [fullPath, fixture] of map.entries()) {
|
|
if (fullPath === p && fixture.dir) {
|
|
// This is the dir itself; not a child. Skip.
|
|
continue
|
|
}
|
|
|
|
if (fullPath.startsWith(p + '/')) {
|
|
const rest = fullPath.slice(p.length + 1)
|
|
|
|
if (!rest.includes('/') && fixture.dir) {
|
|
names.add(rest)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Implicit directories: any nested path under `p` implies the intermediate
|
|
// directory exists (mirrors how mkdir({recursive:true}) populates the tree).
|
|
for (const key of map.keys()) {
|
|
if (!key.startsWith(p + '/')) {
|
|
continue
|
|
}
|
|
|
|
const rest = key.slice(p.length + 1)
|
|
const parts = rest.split('/')
|
|
|
|
if (parts.length < 2) {
|
|
continue
|
|
}
|
|
|
|
names.add(parts[0])
|
|
}
|
|
|
|
const entries: Dirent[] = []
|
|
|
|
for (const name of names) {
|
|
entries.push({
|
|
name,
|
|
isDirectory: () => true,
|
|
isFile: () => false,
|
|
isBlockDevice: () => false,
|
|
isCharacterDevice: () => false,
|
|
isSymbolicLink: () => false,
|
|
isFIFO: () => false,
|
|
isSocket: () => false
|
|
} as unknown as Dirent)
|
|
}
|
|
|
|
if (entries.length === 0 && !map.has(p)) {
|
|
throw new Error(`ENOENT: ${p}`)
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
return { existsSync, readFileSync, statSync, readdirSync }
|
|
}
|
|
|
|
function baseDeps(overrides: Record<string, unknown> = {}) {
|
|
const fs = makeFs({})
|
|
|
|
return {
|
|
legacyActivePath: '/home/u/.hermes/active_profile',
|
|
hermesHome: '/home/u/.hermes',
|
|
profilesRoot: '/home/u/.hermes/profiles',
|
|
existsSync: fs.existsSync,
|
|
readFileSync: fs.readFileSync,
|
|
statSync: fs.statSync,
|
|
readdirSync: fs.readdirSync,
|
|
isHermesProcess: () => true,
|
|
now: () => NOW,
|
|
writeJson: () => {},
|
|
isValidProfileName,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// readLegacyActiveProfile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('readLegacyActiveProfile returns null when file missing', () => {
|
|
assert.equal(
|
|
readLegacyActiveProfile(
|
|
'/missing',
|
|
() => {
|
|
throw new Error('ENOENT')
|
|
},
|
|
isValidProfileName
|
|
),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('readLegacyActiveProfile returns trimmed name on success', () => {
|
|
assert.equal(
|
|
readLegacyActiveProfile('/p', () => ' coder \n' as unknown as string, isValidProfileName),
|
|
'coder'
|
|
)
|
|
})
|
|
|
|
test('readLegacyActiveProfile returns undefined when present but invalid (special chars)', () => {
|
|
assert.equal(
|
|
readLegacyActiveProfile('/p', () => 'BAD NAME!' as unknown as string, isValidProfileName),
|
|
undefined
|
|
)
|
|
})
|
|
|
|
test('readLegacyActiveProfile rejects default as a legacy CLI choice', () => {
|
|
// "default" matches PROFILE_NAME_RE but is implicit; the explicit guard
|
|
// suppresses it so the heuristic rung still fires.
|
|
assert.equal(
|
|
readLegacyActiveProfile('/p', () => 'default' as unknown as string, isValidProfileName),
|
|
undefined
|
|
)
|
|
})
|
|
|
|
test('readLegacyActiveProfile returns null for empty/whitespace content', () => {
|
|
assert.equal(
|
|
readLegacyActiveProfile('/p', () => ' \n' as unknown as string, isValidProfileName),
|
|
null
|
|
)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// findRunningGatewayProfiles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('findRunningGatewayProfiles returns [] when no pid files exist', () => {
|
|
const fs = makeFs({ '/home/u/.hermes/profiles/coder': { dir: true } })
|
|
assert.deepEqual(
|
|
findRunningGatewayProfiles('/home/u/.hermes/profiles', ['coder'], {
|
|
...fs,
|
|
isHermesProcess: () => true
|
|
}),
|
|
[]
|
|
)
|
|
})
|
|
|
|
test('findRunningGatewayProfiles drops stale (non-hermes) recycled PIDs', () => {
|
|
// Two profiles have pid files, but only coder's pid is a live hermes process.
|
|
// The recycled PID at 5678 belongs to an unrelated process (e.g. Chrome).
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/coder/gateway.pid': { content: '{"pid":1234}' },
|
|
'/home/u/.hermes/profiles/writer/gateway.pid': { content: '{"pid":5678}' }
|
|
})
|
|
|
|
const deps = {
|
|
...fs,
|
|
isHermesProcess: (pid: number) => pid === 1234
|
|
}
|
|
|
|
assert.deepEqual(findRunningGatewayProfiles('/home/u/.hermes/profiles', ['coder', 'writer'], deps), ['coder'])
|
|
})
|
|
|
|
test('findRunningGatewayProfiles tolerates malformed pid files', () => {
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/coder/gateway.pid': { content: '{not json' }
|
|
})
|
|
|
|
assert.deepEqual(
|
|
findRunningGatewayProfiles('/home/u/.hermes/profiles', ['coder'], {
|
|
...fs,
|
|
isHermesProcess: () => true
|
|
}),
|
|
[]
|
|
)
|
|
})
|
|
|
|
test('findRunningGatewayProfiles drops non-integer and non-positive PIDs', () => {
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/coder/gateway.pid': { content: '{"pid":-1}' },
|
|
'/home/u/.hermes/profiles/writer/gateway.pid': { content: '{"pid":1.5}' },
|
|
'/home/u/.hermes/profiles/extra/gateway.pid': { content: '{"pid":0}' }
|
|
})
|
|
|
|
assert.deepEqual(
|
|
findRunningGatewayProfiles('/home/u/.hermes/profiles', ['coder', 'writer', 'extra'], {
|
|
...fs,
|
|
isHermesProcess: () => true
|
|
}),
|
|
[]
|
|
)
|
|
})
|
|
|
|
test('findRunningGatewayProfiles preserves order of allProfiles', () => {
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/coder/gateway.pid': { content: '{"pid":1}' },
|
|
'/home/u/.hermes/profiles/writer/gateway.pid': { content: '{"pid":2}' }
|
|
})
|
|
|
|
const deps = { ...fs, isHermesProcess: () => true }
|
|
assert.deepEqual(findRunningGatewayProfiles('/home/u/.hermes/profiles', ['coder', 'writer'], deps), [
|
|
'coder',
|
|
'writer'
|
|
])
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// scoreStateDb
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('scoreStateDb returns null on missing file', () => {
|
|
assert.equal(
|
|
scoreStateDb('/missing', NOW, () => {
|
|
throw new Error('ENOENT')
|
|
}),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('scoreStateDb floors recency weight at 0.1 for ancient files', () => {
|
|
const ancient = scoreStateDb('/p', NOW, () => ({ size: 10 * 1024 * 1024, mtimeMs: 0 }))
|
|
const fresh = scoreStateDb('/p', NOW, () => ({ size: 10 * 1024 * 1024, mtimeMs: NOW - 86_400_000 }))
|
|
// Ancient still scores > 0 because recency is floored at 0.1.
|
|
assert.ok(ancient !== null && ancient > 0)
|
|
assert.ok(fresh !== null && fresh > ancient!)
|
|
})
|
|
|
|
test('scoreStateDb prefers larger DB at similar recency', () => {
|
|
// 409 MB primary workspace vs 28 MB secondary — primary wins even with a
|
|
// slightly newer mtime on the secondary.
|
|
const big = scoreStateDb('/big', NOW, () => ({ size: 409 * 1024 * 1024, mtimeMs: NOW - 86_400_000 }))
|
|
const small = scoreStateDb('/small', NOW, () => ({ size: 28 * 1024 * 1024, mtimeMs: NOW - 60_000 }))
|
|
assert.ok(big !== null && small !== null && big > small)
|
|
})
|
|
|
|
test('scoreStateDb floors size weight at log10(MIN_SIZE) for tiny files', () => {
|
|
// 100 bytes and PROFILE_SCORE_MIN_SIZE_BYTES score the same on the size axis
|
|
// (recency is the same here, so the score is identical).
|
|
const tiny = scoreStateDb('/tiny', NOW, () => ({ size: 100, mtimeMs: NOW - 86_400_000 }))
|
|
const min = scoreStateDb('/min', NOW, () => ({ size: PROFILE_SCORE_MIN_SIZE_BYTES, mtimeMs: NOW - 86_400_000 }))
|
|
assert.equal(tiny, min)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listProfileDirs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('listProfileDirs returns [] for missing profiles root', () => {
|
|
const fs = makeFs({})
|
|
assert.deepEqual(listProfileDirs(baseDeps({ ...fs })), [])
|
|
})
|
|
|
|
test('listProfileDirs includes default and any name passing the regex', () => {
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/default': { dir: true },
|
|
'/home/u/.hermes/profiles/coder': { dir: true },
|
|
'/home/u/.hermes/profiles/writer': { dir: true }
|
|
})
|
|
|
|
assert.deepEqual(listProfileDirs(baseDeps({ ...fs })), ['default', 'coder', 'writer'])
|
|
})
|
|
|
|
test('listProfileDirs skips files (not directories) and invalid names', () => {
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/default': { dir: true },
|
|
'/home/u/.hermes/profiles/some-file.txt': { dir: false },
|
|
'/home/u/.hermes/profiles/UPPERCASE': { dir: true } // regex rejects
|
|
})
|
|
|
|
assert.deepEqual(listProfileDirs(baseDeps({ ...fs })), ['default'])
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// decideMigration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('decideMigration prefers legacy over everything else', () => {
|
|
const deps = baseDeps()
|
|
const d = decideMigration('coder', ['writer'], ['coder', 'writer'], deps, () => null)
|
|
assert.deepEqual(d, { profile: 'coder' })
|
|
assert.equal(d?._migrated, undefined)
|
|
})
|
|
|
|
test('decideMigration prefers a single running gateway profile', () => {
|
|
const deps = baseDeps()
|
|
const d = decideMigration(null, ['coder'], ['coder', 'writer'], deps, () => null)
|
|
assert.deepEqual(d, { profile: 'coder' })
|
|
assert.equal(d?._migrated, undefined)
|
|
})
|
|
|
|
test('decideMigration falls through to scoring when multiple gateways run', () => {
|
|
// When running.length > 1 the heuristic must score the running set, not all
|
|
// profiles, so a stopped profile can't win against two live ones.
|
|
const deps = baseDeps()
|
|
|
|
const d = decideMigration(null, ['coder', 'writer'], ['coder', 'writer'], deps, p =>
|
|
p.endsWith('/writer/state.db') ? 50 : 10
|
|
)
|
|
|
|
assert.deepEqual(d, { profile: 'writer', _migrated: true })
|
|
})
|
|
|
|
test('decideMigration returns null when no candidate scores and legacy is invalid', () => {
|
|
const deps = baseDeps()
|
|
assert.equal(
|
|
decideMigration(undefined, [], ['coder', 'writer'], deps, () => null),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('decideMigration suppresses write when best is default (single-profile fallback)', () => {
|
|
// The whole point of the migration is to migrate AWAY from default when a
|
|
// better candidate exists. If 'default' wins the score, the install is
|
|
// default-primary and we leave it alone. Default's DB is $HERMES_HOME/state.db,
|
|
// not profiles/default/state.db.
|
|
const deps = baseDeps()
|
|
|
|
const d = decideMigration(null, [], ['default', 'coder'], deps, p => (p.endsWith('/.hermes/state.db') ? 99 : 50))
|
|
|
|
assert.equal(d, null)
|
|
})
|
|
|
|
test('decideMigration still flags _migrated when legacy is invalid (undefined) but a heuristic wins', () => {
|
|
// legacy === undefined means "file was present but malformed" — fall through to
|
|
// the heuristic and mark as migrated.
|
|
const deps = baseDeps()
|
|
const d = decideMigration(undefined, [], ['coder'], deps, () => 10)
|
|
assert.deepEqual(d, { profile: 'coder', _migrated: true })
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// migrateActiveProfileIfMissing (orchestrator)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('migrateActiveProfileIfMissing is a no-op when a user-selected preference file exists', () => {
|
|
// No `_migrated` flag = explicit user/CLI choice. Even a huge other profile
|
|
// must not steal the pin.
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/cfg/active-profile.json': { content: '{"profile":"coder"}' },
|
|
'/home/u/.hermes/profiles/coder': { dir: true },
|
|
'/home/u/.hermes/profiles/writer': { dir: true },
|
|
'/home/u/.hermes/profiles/writer/state.db': { size: 400 * 1024 * 1024, mtime: NOW - 86_400_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), false)
|
|
assert.equal(written, null)
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing writes legacy choice with no _migrated flag', () => {
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/active_profile': { content: 'coder' },
|
|
'/home/u/.hermes/profiles/coder': { dir: true },
|
|
'/home/u/.hermes/profiles/writer': { dir: true }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), true)
|
|
assert.deepEqual(written, { profile: 'coder' })
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing writes heuristic choice with _migrated=true', () => {
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/coder/state.db': { size: 50 * 1024 * 1024, mtime: NOW - 86_400_000 },
|
|
'/home/u/.hermes/profiles/writer/state.db': { size: 200 * 1024 * 1024, mtime: NOW - 86_400_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), true)
|
|
assert.deepEqual(written, { profile: 'writer', _migrated: true })
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing is a no-op for single-profile (default-only) installs', () => {
|
|
// No heuristic candidate can beat 'default', so the orchestrator must NOT
|
|
// write a file — preserves legacy launch behavior for the 99% case.
|
|
// Production default DB is ~/.hermes/state.db, not profiles/default/state.db.
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/state.db': { size: 10 * 1024 * 1024, mtime: NOW - 86_400_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), false)
|
|
assert.equal(written, null)
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing is a no-op when no profiles directory exists', () => {
|
|
let written: unknown = null
|
|
const fs = makeFs({})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), false)
|
|
assert.equal(written, null)
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing prefers a single running gateway over heuristics', () => {
|
|
// running=['coder'] regardless of what the heuristic would score — gateway
|
|
// ownership is the strongest signal we have for the active profile.
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/coder/gateway.pid': { content: '{"pid":42}' },
|
|
'/home/u/.hermes/profiles/writer/state.db': { size: 500 * 1024 * 1024, mtime: NOW - 86_400_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
isHermesProcess: (pid: number) => pid === 42,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), true)
|
|
assert.deepEqual(written, { profile: 'coder' })
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Production layout: default is ~/.hermes, not ~/.hermes/profiles/default
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('profileStateDbPath puts default at hermesHome, named under profilesRoot', () => {
|
|
assert.equal(profileStateDbPath('default', '/home/u/.hermes', '/home/u/.hermes/profiles'), '/home/u/.hermes/state.db')
|
|
assert.equal(
|
|
profileStateDbPath('conduit', '/home/u/.hermes', '/home/u/.hermes/profiles'),
|
|
'/home/u/.hermes/profiles/conduit/state.db'
|
|
)
|
|
})
|
|
|
|
test('profileGatewayPidPath puts default at hermesHome', () => {
|
|
assert.equal(
|
|
profileGatewayPidPath('default', '/home/u/.hermes', '/home/u/.hermes/profiles'),
|
|
'/home/u/.hermes/gateway.pid'
|
|
)
|
|
assert.equal(
|
|
profileGatewayPidPath('coder', '/home/u/.hermes', '/home/u/.hermes/profiles'),
|
|
'/home/u/.hermes/profiles/coder/gateway.pid'
|
|
)
|
|
})
|
|
|
|
test('withDefaultCandidate always leads with default and dedupes', () => {
|
|
assert.deepEqual(withDefaultCandidate([]), ['default'])
|
|
assert.deepEqual(withDefaultCandidate(['conduit']), ['default', 'conduit'])
|
|
assert.deepEqual(withDefaultCandidate(['default', 'conduit']), ['default', 'conduit'])
|
|
})
|
|
|
|
test('findRunningGatewayProfiles sees default gateway.pid at hermesHome', () => {
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/gateway.pid': { content: '{"pid":99}' },
|
|
'/home/u/.hermes/profiles/coder/gateway.pid': { content: '{"pid":11}' }
|
|
})
|
|
|
|
assert.deepEqual(
|
|
findRunningGatewayProfiles('/home/u/.hermes/profiles', ['default', 'coder'], {
|
|
...fs,
|
|
hermesHome: '/home/u/.hermes',
|
|
isHermesProcess: pid => pid === 99
|
|
}),
|
|
['default']
|
|
)
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing does not pin a tiny named profile over a large default DB', () => {
|
|
// Regression for #100576: first-boot after update listed only
|
|
// ~/.hermes/profiles/<name>, never scored ~/.hermes/state.db, and wrote
|
|
// { profile: named, _migrated: true }.
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/conduit': { dir: true },
|
|
'/home/u/.hermes/state.db': { size: 409 * 1024 * 1024, mtime: NOW - 86_400_000 },
|
|
'/home/u/.hermes/profiles/conduit/state.db': { size: 2 * 1024 * 1024, mtime: NOW - 60_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), false)
|
|
assert.equal(written, null)
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing still pins a named profile that actually beats default', () => {
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/profiles/work': { dir: true },
|
|
'/home/u/.hermes/state.db': { size: 5 * 1024 * 1024, mtime: NOW - 86_400_000 },
|
|
'/home/u/.hermes/profiles/work/state.db': { size: 200 * 1024 * 1024, mtime: NOW - 86_400_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), true)
|
|
assert.deepEqual(written, { profile: 'work', _migrated: true })
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing does not pin default when only default gateway is running', () => {
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/home/u/.hermes/gateway.pid': { content: '{"pid":7}' },
|
|
'/home/u/.hermes/state.db': { size: 10 * 1024 * 1024, mtime: NOW - 86_400_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
isHermesProcess: (pid: number) => pid === 7,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), false)
|
|
assert.equal(written, null)
|
|
})
|
|
|
|
test('readExistingPreference treats _migrated as heuristic-owned', () => {
|
|
const fs = makeFs({
|
|
'/cfg/active-profile.json': { content: '{"profile":"conduit","_migrated":true}' }
|
|
})
|
|
|
|
assert.deepEqual(readExistingPreference('/cfg/active-profile.json', fs.readFileSync), {
|
|
profile: 'conduit',
|
|
migrated: true
|
|
})
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing repairs a pre-existing heuristic pin when default now wins', () => {
|
|
// Sol P1 / #100576: file already exists with _migrated:true so first-boot
|
|
// skip left affected installs stuck. Re-score and clear.
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/cfg/active-profile.json': { content: '{"profile":"conduit","_migrated":true}' },
|
|
'/home/u/.hermes/profiles/conduit': { dir: true },
|
|
'/home/u/.hermes/state.db': { size: 409 * 1024 * 1024, mtime: NOW - 86_400_000 },
|
|
'/home/u/.hermes/profiles/conduit/state.db': { size: 2 * 1024 * 1024, mtime: NOW - 60_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), true)
|
|
assert.deepEqual(written, { profile: null })
|
|
})
|
|
|
|
test('migrateActiveProfileIfMissing leaves a still-correct heuristic pin alone', () => {
|
|
let written: unknown = null
|
|
|
|
const fs = makeFs({
|
|
'/cfg/active-profile.json': { content: '{"profile":"work","_migrated":true}' },
|
|
'/home/u/.hermes/profiles/work': { dir: true },
|
|
'/home/u/.hermes/state.db': { size: 5 * 1024 * 1024, mtime: NOW - 86_400_000 },
|
|
'/home/u/.hermes/profiles/work/state.db': { size: 200 * 1024 * 1024, mtime: NOW - 86_400_000 }
|
|
})
|
|
|
|
const deps = baseDeps({
|
|
...fs,
|
|
writeJson: (_p: string, payload: unknown) => {
|
|
written = payload
|
|
}
|
|
})
|
|
|
|
assert.equal(migrateActiveProfileIfMissing('/cfg/active-profile.json', deps), false)
|
|
assert.equal(written, null)
|
|
})
|