mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-12 03:41:39 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cc0aceb365
commit
5188aab938
@@ -0,0 +1,30 @@
|
||||
// Shared helper: compile a TypeScript module on the fly and load it as CommonJS,
|
||||
// matching the transpile-on-load pattern used by the other node:test suites
|
||||
// (see audio-effects-executor.test.js). Lets us unit-test the pure config-*.ts
|
||||
// modules without a build step or an electron runtime.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const ts = require('typescript');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
function loadTs(relPath) {
|
||||
const file = path.join(ROOT, relPath);
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
fileName: file,
|
||||
}).outputText;
|
||||
const mod = new Module(file, module);
|
||||
mod.filename = file;
|
||||
mod.paths = Module._nodeModulePaths(path.dirname(file));
|
||||
mod._compile(compiled, file);
|
||||
return mod.exports;
|
||||
}
|
||||
|
||||
module.exports = { loadTs, ROOT };
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { loadTs } = require('./_load-ts');
|
||||
|
||||
// config-bootstrap imports `{ app } from 'electron'`, which in plain node resolves
|
||||
// to the electron path string (no throw); migrateUserData never touches it.
|
||||
const {
|
||||
migrateUserData,
|
||||
schedulePendingDeletion,
|
||||
consumePendingReset,
|
||||
} = loadTs('src/main/config-bootstrap.ts');
|
||||
|
||||
function tmpAppData() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'feedback-bootstrap-'));
|
||||
}
|
||||
|
||||
test('migrates a legacy userData folder into the new one when the new dir is missing', () => {
|
||||
const parent = tmpAppData();
|
||||
const legacy = path.join(parent, 'slopsmith-desktop');
|
||||
fs.mkdirSync(path.join(legacy, 'slopsmith-config'), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacy, 'slopsmith-config', 'config.json'), '{"dlc_dir":"/x"}');
|
||||
fs.writeFileSync(path.join(legacy, 'slopsmith-desktop.json'), '{"lanAccess":false}');
|
||||
|
||||
const newUserData = path.join(parent, 'feedback-desktop');
|
||||
const res = migrateUserData(newUserData, '2026-06-26T00:00:00.000Z');
|
||||
|
||||
assert.equal(res.migrated, true);
|
||||
assert.equal(res.from, legacy);
|
||||
assert.ok(fs.existsSync(path.join(newUserData, 'slopsmith-desktop.json')), 'top-level file copied');
|
||||
assert.ok(fs.existsSync(path.join(newUserData, 'slopsmith-config', 'config.json')), 'nested file copied');
|
||||
assert.ok(fs.existsSync(path.join(newUserData, 'userdata-migrated.json')), 'migration marker written');
|
||||
// Legacy left in place as a fallback (copy, not move).
|
||||
assert.ok(fs.existsSync(path.join(legacy, 'slopsmith-desktop.json')), 'legacy preserved');
|
||||
});
|
||||
|
||||
test('does NOT overwrite when the new userData dir already exists', () => {
|
||||
const parent = tmpAppData();
|
||||
const legacy = path.join(parent, 'fee[dB]ack');
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacy, 'old.json'), 'legacy');
|
||||
|
||||
const newUserData = path.join(parent, 'feedback-desktop');
|
||||
fs.mkdirSync(newUserData, { recursive: true });
|
||||
fs.writeFileSync(path.join(newUserData, 'keep.json'), 'mine');
|
||||
|
||||
const res = migrateUserData(newUserData, '2026-06-26T00:00:00.000Z');
|
||||
|
||||
assert.equal(res.migrated, false);
|
||||
assert.match(res.reason, /already exists/);
|
||||
assert.ok(fs.existsSync(path.join(newUserData, 'keep.json')), 'existing data untouched');
|
||||
assert.ok(!fs.existsSync(path.join(newUserData, 'old.json')), 'legacy not copied over existing dir');
|
||||
});
|
||||
|
||||
test('no-op when there is no legacy folder to migrate from', () => {
|
||||
const parent = tmpAppData();
|
||||
const newUserData = path.join(parent, 'feedback-desktop');
|
||||
const res = migrateUserData(newUserData, '2026-06-26T00:00:00.000Z');
|
||||
assert.equal(res.migrated, false);
|
||||
assert.match(res.reason, /no legacy/);
|
||||
assert.ok(!fs.existsSync(newUserData), 'new dir not created when nothing to migrate');
|
||||
});
|
||||
|
||||
test('schedulePendingDeletion + consumePendingReset defer and then apply deletions', () => {
|
||||
const userData = tmpAppData();
|
||||
const held1 = path.join(userData, 'Local Storage');
|
||||
const held2 = path.join(userData, 'Crashpad');
|
||||
fs.mkdirSync(held1, { recursive: true });
|
||||
fs.mkdirSync(held2, { recursive: true });
|
||||
fs.writeFileSync(path.join(held1, 'leveldb'), 'x');
|
||||
|
||||
assert.equal(schedulePendingDeletion(userData, [held1]), true);
|
||||
assert.equal(schedulePendingDeletion(userData, [held2, held1]), true); // merge + de-dup
|
||||
assert.equal(schedulePendingDeletion(userData, []), true); // nothing to do
|
||||
const manifest = path.join(userData, 'pending-reset.json');
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(manifest, 'utf8')).sort(), [held1, held2].sort());
|
||||
|
||||
// Nothing deleted yet — deletion is deferred to next launch.
|
||||
assert.ok(fs.existsSync(held1));
|
||||
|
||||
const applied = consumePendingReset(userData);
|
||||
assert.deepEqual(applied.sort(), [held1, held2].sort());
|
||||
assert.ok(!fs.existsSync(held1), 'deferred path removed on consume');
|
||||
assert.ok(!fs.existsSync(held2), 'deferred path removed on consume');
|
||||
assert.ok(!fs.existsSync(manifest), 'manifest cleared after consume');
|
||||
|
||||
// Idempotent: a second consume with no manifest is a no-op.
|
||||
assert.deepEqual(consumePendingReset(userData), []);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { loadTs } = require('./_load-ts');
|
||||
|
||||
const {
|
||||
runConfigMigrations,
|
||||
readSchemaVersion,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
} = loadTs('src/main/config-migrations.ts');
|
||||
|
||||
function tmpConfigDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'feedback-cfgmig-'));
|
||||
}
|
||||
|
||||
test('first run stamps the config to CURRENT_SCHEMA_VERSION and is idempotent', () => {
|
||||
const dir = tmpConfigDir();
|
||||
assert.equal(readSchemaVersion(dir), 0);
|
||||
|
||||
const first = runConfigMigrations(dir, '1.2.3', '2026-06-26T00:00:00.000Z');
|
||||
assert.equal(first.from, 0);
|
||||
assert.equal(first.to, CURRENT_SCHEMA_VERSION);
|
||||
assert.equal(readSchemaVersion(dir), CURRENT_SCHEMA_VERSION);
|
||||
|
||||
const stamp = JSON.parse(fs.readFileSync(path.join(dir, 'config_version.json'), 'utf8'));
|
||||
assert.equal(stamp.schemaVersion, CURRENT_SCHEMA_VERSION);
|
||||
assert.equal(stamp.appVersion, '1.2.3');
|
||||
assert.equal(stamp.updatedAt, '2026-06-26T00:00:00.000Z');
|
||||
|
||||
// Second run is a no-op: nothing to migrate, stamp unchanged.
|
||||
const second = runConfigMigrations(dir, '1.2.3', '2026-06-26T01:00:00.000Z');
|
||||
assert.equal(second.from, CURRENT_SCHEMA_VERSION);
|
||||
assert.equal(second.to, CURRENT_SCHEMA_VERSION);
|
||||
assert.deepEqual(second.ran, []);
|
||||
});
|
||||
|
||||
test('fail-soft: a throwing migration is logged and skipped; later migrations still run', () => {
|
||||
const dir = tmpConfigDir();
|
||||
const okMarker = path.join(dir, 'ok-ran.txt');
|
||||
|
||||
// Two version-1 migrations (both in range for a fresh dir). The first throws;
|
||||
// the runner must not abort — the second must still execute.
|
||||
const registry = [
|
||||
{ version: 1, name: 'boom', run: () => { throw new Error('boom'); } },
|
||||
{ version: 1, name: 'ok', run: () => fs.writeFileSync(okMarker, 'ran') },
|
||||
];
|
||||
|
||||
const res = runConfigMigrations(dir, '9.9.9', '2026-06-26T00:00:00.000Z', registry);
|
||||
|
||||
assert.equal(res.ran.length, 2);
|
||||
assert.equal(res.ran[0].ok, false);
|
||||
assert.match(res.ran[0].error, /boom/);
|
||||
assert.equal(res.ran[1].ok, true);
|
||||
assert.ok(fs.existsSync(okMarker), 'the second migration ran despite the first throwing');
|
||||
// The stamp still advances so a persistently-failing migration cannot wedge startup.
|
||||
assert.equal(readSchemaVersion(dir), CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test('readSchemaVersion treats a missing/corrupt stamp as version 0', () => {
|
||||
const dir = tmpConfigDir();
|
||||
assert.equal(readSchemaVersion(dir), 0);
|
||||
fs.writeFileSync(path.join(dir, 'config_version.json'), 'not json {');
|
||||
assert.equal(readSchemaVersion(dir), 0);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { loadTs } = require('./_load-ts');
|
||||
|
||||
// resetConfig itself is electron/python-bound, but its delete logic is the pure
|
||||
// enumerateConfigPaths → buildDeleteSet → deletePaths pipeline (config-paths.ts).
|
||||
// We exercise that pipeline against a real on-disk fake tree to prove the
|
||||
// "library & plugins preserved" guarantees end-to-end.
|
||||
const { enumerateConfigPaths, buildDeleteSet, deletePaths } = loadTs('src/main/config-paths.ts');
|
||||
|
||||
function buildFakeTree() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'feedback-reset-'));
|
||||
const userData = path.join(root, 'feedback-desktop');
|
||||
const configDir = path.join(userData, 'slopsmith-config');
|
||||
const pluginsDir = path.join(userData, 'plugins');
|
||||
const dlcDir = path.join(root, 'Library'); // song library lives OUTSIDE userData
|
||||
const cacheBase = path.join(root, '.cache');
|
||||
|
||||
const write = (p, c = 'x') => {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, c);
|
||||
};
|
||||
|
||||
// App settings & caches
|
||||
write(path.join(userData, 'slopsmith-desktop.json'));
|
||||
write(path.join(userData, 'slopsmith-audio-settings.json'));
|
||||
write(path.join(userData, 'soundfonts', 'FluidR3_GM.sf2'));
|
||||
write(path.join(userData, 'known-plugins.xml'));
|
||||
// Plugin state & python deps
|
||||
write(path.join(configDir, 'plugin_state.json'));
|
||||
write(path.join(configDir, 'pip_packages', 'somepkg', '__init__.py'));
|
||||
write(path.join(configDir, 'plugin_data', 'foo.json'));
|
||||
// Backend DBs + config (incl. WAL/SHM sidecars left by an abrupt stop)
|
||||
write(path.join(configDir, 'web_library.db'));
|
||||
write(path.join(configDir, 'web_library.db-wal'));
|
||||
write(path.join(configDir, 'web_library.db-shm'));
|
||||
write(path.join(configDir, 'config.json'));
|
||||
write(path.join(configDir, 'config_version.json'));
|
||||
// Protected: installed plugin, song library, ML caches
|
||||
write(path.join(pluginsDir, 'my-plugin', 'plugin.json'));
|
||||
write(path.join(dlcDir, 'song.psarc'));
|
||||
write(path.join(cacheBase, 'torch', 'model.pt'));
|
||||
write(path.join(cacheBase, 'huggingface', 'blob'));
|
||||
|
||||
const env = {
|
||||
platform: process.platform,
|
||||
userData,
|
||||
home: root,
|
||||
configDir,
|
||||
dlcDir,
|
||||
pluginsDir,
|
||||
cacheBase,
|
||||
torchHome: path.join(cacheBase, 'torch'),
|
||||
hfHome: path.join(cacheBase, 'huggingface'),
|
||||
};
|
||||
return { root, env, userData, configDir, pluginsDir, dlcDir, cacheBase };
|
||||
}
|
||||
|
||||
function run(selection, env) {
|
||||
const cats = enumerateConfigPaths(env);
|
||||
return deletePaths(buildDeleteSet(selection, cats));
|
||||
}
|
||||
|
||||
function protectedIntact(t) {
|
||||
assert.ok(fs.existsSync(path.join(t.pluginsDir, 'my-plugin', 'plugin.json')), 'installed plugin preserved');
|
||||
assert.ok(fs.existsSync(path.join(t.dlcDir, 'song.psarc')), 'song library preserved');
|
||||
assert.ok(fs.existsSync(path.join(t.cacheBase, 'torch', 'model.pt')), 'ML cache preserved');
|
||||
}
|
||||
|
||||
test('appSettings reset removes desktop prefs/caches but preserves DBs, plugins and library', () => {
|
||||
const t = buildFakeTree();
|
||||
run({ appSettings: true }, t.env);
|
||||
assert.ok(!fs.existsSync(path.join(t.userData, 'slopsmith-desktop.json')), 'pref deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.userData, 'soundfonts')), 'soundfont cache deleted');
|
||||
assert.ok(fs.existsSync(path.join(t.configDir, 'web_library.db')), 'DB preserved');
|
||||
assert.ok(fs.existsSync(path.join(t.configDir, 'plugin_state.json')), 'plugin state preserved');
|
||||
protectedIntact(t);
|
||||
});
|
||||
|
||||
test('pluginState reset clears plugin state/py-deps but keeps installed plugins, DBs and library', () => {
|
||||
const t = buildFakeTree();
|
||||
run({ pluginState: true }, t.env);
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'plugin_state.json')), 'plugin_state deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'pip_packages')), 'pip_packages deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'plugin_data')), 'plugin_data deleted');
|
||||
assert.ok(fs.existsSync(path.join(t.configDir, 'web_library.db')), 'DB preserved');
|
||||
assert.ok(fs.existsSync(path.join(t.userData, 'slopsmith-desktop.json')), 'app prefs preserved');
|
||||
protectedIntact(t);
|
||||
});
|
||||
|
||||
test('fullReset (no opt-ins) wipes config DBs but still preserves library and installed plugins', () => {
|
||||
const t = buildFakeTree();
|
||||
run({ fullReset: true }, t.env);
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'web_library.db')), 'DB deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'web_library.db-wal')), 'WAL sidecar deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'web_library.db-shm')), 'SHM sidecar deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'config.json')), 'config.json deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'config_version.json')), 'migration stamp deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.userData, 'slopsmith-desktop.json')), 'app prefs deleted');
|
||||
assert.ok(!fs.existsSync(path.join(t.configDir, 'plugin_state.json')), 'plugin state deleted');
|
||||
protectedIntact(t);
|
||||
});
|
||||
|
||||
test('opt-in flags remove the protected trees', () => {
|
||||
const t = buildFakeTree();
|
||||
run(
|
||||
{ fullReset: true, alsoInstalledPlugins: true, alsoSongLibrary: true, alsoMlCaches: true },
|
||||
t.env,
|
||||
);
|
||||
assert.ok(!fs.existsSync(t.pluginsDir), 'installed plugins removed on opt-in');
|
||||
assert.ok(!fs.existsSync(t.dlcDir), 'song library removed on opt-in');
|
||||
assert.ok(!fs.existsSync(path.join(t.cacheBase, 'torch')), 'torch cache removed on opt-in');
|
||||
assert.ok(!fs.existsSync(path.join(t.cacheBase, 'huggingface')), 'hf cache removed on opt-in');
|
||||
});
|
||||
|
||||
test('deletePaths is fail-soft on a non-existent path', () => {
|
||||
const t = buildFakeTree();
|
||||
const missing = path.join(t.root, 'does-not-exist');
|
||||
const [entry] = deletePaths([missing]);
|
||||
assert.equal(entry.ok, true);
|
||||
assert.equal(entry.existed, false);
|
||||
});
|
||||
Reference in New Issue
Block a user