feedBack-desktop/tests/config-paths.test.js
Byron Gamatos 5188aab938
feat(config): real config reset/repair + migration framework (drop manual-delete) (#38)
Eliminates the fragile "delete the config folder before upgrading" tester
instruction, which was wrong-by-OS because the userData folder name was
derived inconsistently per platform (fee[dB]ack on macOS, slopsmith-desktop
on Linux/Windows).

A. Deterministic paths + migration framework
- Pin the userData name on every OS via app.setName('feedback-desktop') +
  build.extraMetadata.name; brand (productName 'fee[dB]ack') unchanged.
- One-time userData migration copies a legacy folder into the new one so
  upgraded users don't start fresh (atomic copy-then-rename, fail-soft).
  Runs before the single-instance lock / crashReporter, which would otherwise
  create userData and defeat the "new dir doesn't exist" gate.
- config-migrations.ts: versioned, ordered, idempotent, fail-soft migration
  runner stamped in CONFIG_DIR/config_version.json; logs the active CONFIG_DIR
  at startup (closes the Linux ~/.local/share/slopsmith shared-config gap).

B. In-app "Reset / repair configuration" (Settings panel)
- Granular options: reset app settings & caches, clear plugin state & cached
  Python deps, and full reset with default-OFF opt-ins for installed plugins /
  song library / ML caches.
- config-paths.ts is the single source of truth for per-OS path enumeration;
  the song library, installed plugins and ML caches are structurally confined
  to optInExtras and never wiped by the safe/full categories.
- Reset stops the backend, deletes immediate paths, includes SQLite WAL/SHM
  sidecars + the migration stamp on full reset, and defers Chromium/Crashpad
  state to next launch (consumed before any window reopens it). ML caches honor
  TORCH_HOME/HF_HOME. Empty selection is a no-op (backend left running).
- SECURITY: destructive resets require a native main-process confirmation
  dialog — the renderer bridge is reachable by plugin scripts, so a
  renderer-only confirm is not a sufficient gate.

Tests: node:test suites for path enumeration (per-OS + library/plugins
preserved), migration idempotency/fail-soft, reset delete pipeline guarantees,
userData migration, and deferred-deletion schedule/consume. `npm test` green
(adds a test script). codex review --base origin/main clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:13:24 +02:00

192 lines
8.2 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { loadTs } = require('./_load-ts');
const {
enumerateConfigPaths,
buildDeleteSet,
partitionDeferred,
isSharedDockerConfig,
DEFERRED_BASENAMES,
} = loadTs('src/main/config-paths.ts');
// Per-OS resolved path envs. enumerateConfigPaths builds everything from these
// values, so we simulate each platform by passing OS-shaped paths.
const ENVS = {
linux: {
platform: 'linux',
userData: '/home/u/.config/feedback-desktop',
home: '/home/u',
configDir: '/home/u/.config/feedback-desktop/slopsmith-config',
dlcDir: '/home/u/Music/Slopsmith',
pluginsDir: '/home/u/.config/feedback-desktop/plugins',
cacheBase: '/home/u/.cache',
torchHome: '/home/u/.cache/torch',
hfHome: '/home/u/.cache/huggingface',
},
darwin: {
platform: 'darwin',
userData: '/Users/u/Library/Application Support/feedback-desktop',
home: '/Users/u',
configDir: '/Users/u/Library/Application Support/feedback-desktop/slopsmith-config',
dlcDir: '/Users/u/Music/Slopsmith',
pluginsDir: '/Users/u/Library/Application Support/feedback-desktop/plugins',
cacheBase: '/Users/u/.cache',
torchHome: '/Users/u/.cache/torch',
hfHome: '/Users/u/.cache/huggingface',
},
win32: {
platform: 'win32',
userData: '/c/Users/u/AppData/Roaming/feedback-desktop',
home: '/c/Users/u',
configDir: '/c/Users/u/AppData/Roaming/feedback-desktop/slopsmith-config',
dlcDir: '/c/Users/u/Music/Slopsmith',
pluginsDir: '/c/Users/u/AppData/Roaming/feedback-desktop/plugins',
cacheBase: '/c/Users/u/.cache',
torchHome: '/c/Users/u/.cache/torch',
hfHome: '/c/Users/u/.cache/huggingface',
},
};
test('enumerateConfigPaths places known desktop state under userData (each OS)', () => {
for (const [name, env] of Object.entries(ENVS)) {
const cats = enumerateConfigPaths(env);
const u = env.userData;
const c = env.configDir;
for (const expected of [
path.join(u, 'slopsmith-desktop.json'),
path.join(u, 'slopsmith-audio-settings.json'),
path.join(u, 'soundfonts'),
path.join(u, 'vst-load-sentinel.json'),
path.join(u, 'vst-crash-blocklist.json'),
path.join(u, 'known-plugins.xml'),
path.join(u, 'Crashpad'),
]) {
assert.ok(cats.appSettingsAndCaches.includes(expected), `${name}: missing ${expected}`);
}
assert.ok(cats.pluginStateAndPyDeps.includes(path.join(c, 'plugin_state.json')), `${name}: plugin_state`);
assert.ok(cats.pluginStateAndPyDeps.includes(path.join(c, 'pip_packages')), `${name}: pip_packages`);
assert.ok(cats.configDbsAndState.includes(path.join(c, 'web_library.db')), `${name}: web_library.db`);
assert.ok(cats.configDbsAndState.includes(path.join(c, 'config.json')), `${name}: config.json`);
// WAL/SHM sidecars must be cleared alongside each DB.
assert.ok(cats.configDbsAndState.includes(path.join(c, 'web_library.db-wal')), `${name}: db-wal`);
assert.ok(cats.configDbsAndState.includes(path.join(c, 'web_library.db-shm')), `${name}: db-shm`);
// The migration stamp is part of a full reset so migrations re-run after.
assert.ok(cats.configDbsAndState.includes(path.join(c, 'config_version.json')), `${name}: stamp`);
}
});
test('buildDeleteSet returns an empty set for an empty/all-false selection', () => {
const cats = enumerateConfigPaths(ENVS.linux);
assert.deepEqual(buildDeleteSet({}, cats), []);
assert.deepEqual(buildDeleteSet({ appSettings: false, fullReset: false }, cats), []);
});
test('partitionDeferred routes Chromium-held paths to deferred, the rest to immediate', () => {
const env = ENVS.linux;
const cats = enumerateConfigPaths(env);
const { immediate, deferred } = partitionDeferred(buildDeleteSet({ fullReset: true }, cats));
// Every deferred path has a Chromium/Crashpad basename.
for (const p of deferred) {
assert.ok(DEFERRED_BASENAMES.includes(path.basename(p)), `unexpected deferred ${p}`);
}
// Crashpad + the 5 Electron-state dirs are deferred; nothing else is.
assert.deepEqual(
deferred.map((p) => path.basename(p)).sort(),
[...DEFERRED_BASENAMES].sort(),
);
// DBs and prefs go immediate, never deferred.
assert.ok(immediate.includes(path.join(env.configDir, 'web_library.db')));
assert.ok(immediate.includes(path.join(env.userData, 'slopsmith-desktop.json')));
assert.ok(!immediate.some((p) => DEFERRED_BASENAMES.includes(path.basename(p))));
});
test('SAFETY: song library, installed plugins and ML caches are ONLY in optInExtras', () => {
for (const [name, env] of Object.entries(ENVS)) {
const cats = enumerateConfigPaths(env);
const safe = [
...cats.appSettingsAndCaches,
...cats.pluginStateAndPyDeps,
...cats.configDbsAndState,
];
// None of the safe categories may equal or be a child of the protected dirs.
const protectedRoots = [
env.dlcDir,
env.pluginsDir,
path.join(env.cacheBase, 'torch'),
path.join(env.cacheBase, 'huggingface'),
];
for (const root of protectedRoots) {
assert.ok(!safe.includes(root), `${name}: ${root} leaked into a safe category`);
assert.ok(
!safe.some((p) => p === root || p.startsWith(root + path.sep)),
`${name}: a safe path lives under protected ${root}`,
);
}
// And they ARE present in optInExtras.
assert.deepEqual(cats.optInExtras.songLibrary, [env.dlcDir], `${name}: songLibrary`);
assert.deepEqual(cats.optInExtras.installedPlugins, [env.pluginsDir], `${name}: installedPlugins`);
assert.deepEqual(
cats.optInExtras.mlCaches,
[path.join(env.cacheBase, 'torch'), path.join(env.cacheBase, 'huggingface')],
`${name}: mlCaches`,
);
}
});
test('buildDeleteSet honors flags and never widens to opt-in extras implicitly', () => {
const env = ENVS.linux;
const cats = enumerateConfigPaths(env);
const extras = [
env.dlcDir,
env.pluginsDir,
...cats.optInExtras.mlCaches,
];
const appOnly = buildDeleteSet({ appSettings: true }, cats);
assert.deepEqual(appOnly, cats.appSettingsAndCaches);
extras.forEach((p) => assert.ok(!appOnly.includes(p), `appSettings leaked ${p}`));
const pluginOnly = buildDeleteSet({ pluginState: true }, cats);
assert.deepEqual(pluginOnly, cats.pluginStateAndPyDeps);
const full = buildDeleteSet({ fullReset: true }, cats);
for (const p of [...cats.appSettingsAndCaches, ...cats.pluginStateAndPyDeps, ...cats.configDbsAndState]) {
assert.ok(full.includes(p), `fullReset missing ${p}`);
}
extras.forEach((p) => assert.ok(!full.includes(p), `fullReset leaked ${p} without opt-in`));
const fullPlusAll = buildDeleteSet(
{ fullReset: true, alsoInstalledPlugins: true, alsoSongLibrary: true, alsoMlCaches: true },
cats,
);
extras.forEach((p) => assert.ok(fullPlusAll.includes(p), `opt-in missing ${p}`));
const installedOnly = buildDeleteSet({ alsoInstalledPlugins: true }, cats);
assert.deepEqual(installedOnly, [env.pluginsDir]);
// De-duplication: app + full must not double-list shared app paths.
const merged = buildDeleteSet({ appSettings: true, fullReset: true }, cats);
assert.equal(merged.length, new Set(merged).size);
});
test('mlCaches honors custom TORCH_HOME / HF_HOME locations', () => {
const env = {
...ENVS.linux,
torchHome: '/mnt/big/torch',
hfHome: '/mnt/big/hf',
};
const cats = enumerateConfigPaths(env);
assert.deepEqual(cats.optInExtras.mlCaches, ['/mnt/big/torch', '/mnt/big/hf']);
});
test('isSharedDockerConfig detects the Linux shared ~/.local/share/slopsmith dir', () => {
assert.equal(
isSharedDockerConfig({ ...ENVS.linux, configDir: '/home/u/.local/share/slopsmith' }),
true,
);
assert.equal(isSharedDockerConfig(ENVS.linux), false);
});