222 lines
9.2 KiB
JavaScript
222 lines
9.2 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
import crypto from 'crypto';
|
|
import fsPromises from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { createProjectIdFromPath } from '../projects/project-id.js';
|
|
import { createSettingsRuntime } from './settings-runtime.js';
|
|
|
|
const createRuntime = async () => {
|
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
|
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
|
const runtime = createSettingsRuntime({
|
|
fsPromises,
|
|
path,
|
|
crypto,
|
|
SETTINGS_FILE_PATH: settingsFilePath,
|
|
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
|
|
sanitizeSettingsUpdate: (settings) => settings,
|
|
mergePersistedSettings: (_current, changes) => changes,
|
|
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
|
|
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
|
|
formatSettingsResponse: (settings) => settings,
|
|
resolveDirectoryCandidate: (value) => value,
|
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
normalizeManagedRemoteTunnelPresets: (value) => value,
|
|
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
|
|
syncManagedRemoteTunnelConfigWithPresets: async () => {},
|
|
upsertManagedRemoteTunnelToken: async () => {},
|
|
});
|
|
|
|
return {
|
|
runtime,
|
|
settingsFilePath,
|
|
tempRoot,
|
|
cleanup: async () => {
|
|
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
|
},
|
|
};
|
|
};
|
|
|
|
describe('settings runtime', () => {
|
|
it('round-trips shared sidebar preferences through settings.json', async () => {
|
|
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
|
const preferences = {
|
|
sidebarProjectDisplayMode: 'single',
|
|
sidebarSessionGroupingMode: 'flat',
|
|
sidebarProjectSortOrder: 'date-added',
|
|
sidebarShowRecentSection: false,
|
|
};
|
|
try {
|
|
await runtime.persistSettings(preferences);
|
|
|
|
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
|
|
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')('writes settings with restrictive directory and file permissions', async () => {
|
|
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
try {
|
|
await runtime.writeSettingsToDisk({ desktopUiPassword: 'secret' });
|
|
|
|
expect((await fsPromises.stat(tempRoot)).mode & 0o777).toBe(0o700);
|
|
expect((await fsPromises.stat(settingsFilePath)).mode & 0o777).toBe(0o600);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('only remaps project plan paths within the migrated storage directory', async () => {
|
|
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
try {
|
|
const projectPath = path.join(tempRoot, 'project');
|
|
const oldProjectId = 'legacy-project-id';
|
|
const newProjectId = createProjectIdFromPath(projectPath);
|
|
const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects');
|
|
const oldStorageDir = path.join(projectsRoot, oldProjectId);
|
|
const newStorageDir = path.join(projectsRoot, newProjectId);
|
|
const siblingStorageDir = `${oldStorageDir}-sibling`;
|
|
|
|
await fsPromises.mkdir(projectPath, { recursive: true });
|
|
await fsPromises.mkdir(projectsRoot, { recursive: true });
|
|
await fsPromises.writeFile(
|
|
settingsFilePath,
|
|
JSON.stringify({
|
|
projects: [{ id: oldProjectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }],
|
|
activeProjectId: oldProjectId,
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
await fsPromises.writeFile(
|
|
path.join(projectsRoot, `${oldProjectId}.json`),
|
|
JSON.stringify({
|
|
projectPlanFiles: [
|
|
{ id: 'inside', path: path.join(oldStorageDir, 'plans', 'inside.md') },
|
|
{ id: 'sibling', path: path.join(siblingStorageDir, 'plans', 'outside.md') },
|
|
],
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
await runtime.readSettingsFromDiskMigrated();
|
|
|
|
const migratedConfig = JSON.parse(await fsPromises.readFile(path.join(projectsRoot, `${newProjectId}.json`), 'utf8'));
|
|
expect(migratedConfig.projectPlanFiles).toEqual([
|
|
{ id: 'inside', path: path.join(newStorageDir, 'plans', 'inside.md') },
|
|
{ id: 'sibling', path: path.join(siblingStorageDir, 'plans', 'outside.md') },
|
|
]);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
|
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
|
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
|
const wrappedFs = {
|
|
...fsPromises,
|
|
rename: async () => {
|
|
const error = new Error('operation not permitted');
|
|
error.code = 'EPERM';
|
|
throw error;
|
|
},
|
|
};
|
|
const runtime = createSettingsRuntime({
|
|
fsPromises: wrappedFs,
|
|
path,
|
|
crypto,
|
|
SETTINGS_FILE_PATH: settingsFilePath,
|
|
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
|
|
sanitizeSettingsUpdate: (settings) => settings,
|
|
mergePersistedSettings: (_current, changes) => changes,
|
|
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
|
|
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
|
|
formatSettingsResponse: (settings) => settings,
|
|
resolveDirectoryCandidate: (value) => value,
|
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
normalizeManagedRemoteTunnelPresets: (value) => value,
|
|
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
|
|
syncManagedRemoteTunnelConfigWithPresets: async () => {},
|
|
upsertManagedRemoteTunnelToken: async () => {},
|
|
});
|
|
|
|
try {
|
|
await runtime.writeSettingsToDisk({ theme: 'dark' });
|
|
|
|
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify({ theme: 'dark' }, null, 2));
|
|
} finally {
|
|
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
|
|
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
try {
|
|
const settingsDir = path.dirname(settingsFilePath);
|
|
const orphan1 = path.join(settingsDir, 'settings.json.tmp-1234-11111-abc');
|
|
const orphan2 = path.join(settingsDir, 'settings.json.tmp-5678-22222-def');
|
|
const unrelated = path.join(settingsDir, 'other-file.json');
|
|
|
|
await fsPromises.writeFile(orphan1, '{"broken": true}', 'utf8');
|
|
await fsPromises.writeFile(orphan2, '{"broken": true}', 'utf8');
|
|
await fsPromises.writeFile(unrelated, '{"keep": true}', 'utf8');
|
|
await fsPromises.writeFile(settingsFilePath, '{"theme": "light"}', 'utf8');
|
|
|
|
await runtime.readSettingsFromDiskMigrated();
|
|
|
|
const files = await fsPromises.readdir(settingsDir);
|
|
expect(files).toContain('settings.json');
|
|
expect(files).toContain('other-file.json');
|
|
expect(files).not.toContain('settings.json.tmp-1234-11111-abc');
|
|
expect(files).not.toContain('settings.json.tmp-5678-22222-def');
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('removes temp file when writeSettingsToDisk encounters a write error', async () => {
|
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
|
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
|
let capturedTmp = null;
|
|
const wrappedFs = {
|
|
...fsPromises,
|
|
rename: async (src, dst) => {
|
|
capturedTmp = src;
|
|
const error = new Error('unexpected disk failure');
|
|
error.code = 'EIO';
|
|
throw error;
|
|
},
|
|
};
|
|
const runtime = createSettingsRuntime({
|
|
fsPromises: wrappedFs,
|
|
path,
|
|
crypto,
|
|
SETTINGS_FILE_PATH: settingsFilePath,
|
|
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
|
|
sanitizeSettingsUpdate: (settings) => settings,
|
|
mergePersistedSettings: (_current, changes) => changes,
|
|
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
|
|
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
|
|
formatSettingsResponse: (settings) => settings,
|
|
resolveDirectoryCandidate: (value) => value,
|
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
normalizeManagedRemoteTunnelPresets: (value) => value,
|
|
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
|
|
syncManagedRemoteTunnelConfigWithPresets: async () => {},
|
|
upsertManagedRemoteTunnelToken: async () => {},
|
|
});
|
|
|
|
try {
|
|
await expect(runtime.writeSettingsToDisk({ theme: 'dark' })).rejects.toThrow('unexpected disk failure');
|
|
expect(capturedTmp).toBeTruthy();
|
|
const files = await fsPromises.readdir(tempRoot);
|
|
expect(files.some((f) => f.startsWith('settings.json.tmp-'))).toBe(false);
|
|
} finally {
|
|
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|