test: characterize runtime settings hook merge

This commit is contained in:
777genius 2026-04-29 17:55:19 +03:00
parent 90401b41a1
commit b2edfe5d2a
2 changed files with 136 additions and 0 deletions

View file

@ -1,5 +1,7 @@
type JsonObject = Record<string, unknown>;
type JsonArray = unknown[];
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@ -13,10 +15,77 @@ function parseJsonSettingsObject(raw: string): JsonObject | null {
}
}
function isHookEntry(value: unknown): value is JsonObject {
return isJsonObject(value) && Array.isArray(value.hooks);
}
function getHookEntryDedupeKey(value: unknown): string | null {
if (!isHookEntry(value)) {
return null;
}
const commands = value.hooks
.map((hook) => (isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null))
.filter((command): command is string => Boolean(command));
if (commands.length === 0) {
return null;
}
return JSON.stringify({
matcher: typeof value.matcher === 'string' ? value.matcher : '',
commands,
});
}
function mergeHookEntryArrays(target: JsonArray, source: JsonArray): JsonArray {
const merged = [...target];
const seen = new Set<string>();
for (const entry of merged) {
const key = getHookEntryDedupeKey(entry);
if (key) {
seen.add(key);
}
}
for (const entry of source) {
const key = getHookEntryDedupeKey(entry);
if (key && seen.has(key)) {
continue;
}
merged.push(entry);
if (key) {
seen.add(key);
}
}
return merged;
}
function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject {
const merged: JsonObject = { ...target };
for (const [hookName, sourceValue] of Object.entries(source)) {
const currentValue = merged[hookName];
if (Array.isArray(currentValue) && Array.isArray(sourceValue)) {
merged[hookName] = mergeHookEntryArrays(currentValue, sourceValue);
continue;
}
if (isJsonObject(currentValue) && isJsonObject(sourceValue)) {
merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue);
continue;
}
merged[hookName] = sourceValue;
}
return merged;
}
function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject {
const merged: JsonObject = { ...target };
for (const [key, value] of Object.entries(source)) {
const current = merged[key];
if (key === 'hooks' && isJsonObject(current) && isJsonObject(value)) {
merged[key] = mergeHooksObject(current, value);
continue;
}
if (isJsonObject(current) && isJsonObject(value)) {
merged[key] = deepMergeJsonObjects(current, value);
continue;

View file

@ -64,4 +64,71 @@ describe('mergeJsonSettingsArgs', () => {
'{"fastMode":false,"codex":{"forced_login_method":"chatgpt"}}',
]);
});
it('preserves multiple hook entries for the same hook event', () => {
const merged = mergeJsonSettingsArgs([
'--settings',
JSON.stringify({
hooks: {
Stop: [
{
matcher: '',
hooks: [{ type: 'command', command: '/bin/sh user-stop.sh' }],
},
],
},
}),
'--settings',
JSON.stringify({
hooks: {
Stop: [
{
matcher: '',
hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }],
},
],
},
}),
]);
expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({
hooks: {
Stop: [
{
matcher: '',
hooks: [{ type: 'command', command: '/bin/sh user-stop.sh' }],
},
{
matcher: '',
hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }],
},
],
},
});
});
it('dedupes identical hook entries while preserving unrelated array replacement semantics', () => {
const appHook = {
matcher: '',
hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }],
};
const merged = mergeJsonSettingsArgs([
'--settings',
JSON.stringify({
permissions: { allow: ['Read'] },
hooks: { Stop: [appHook] },
}),
'--settings',
JSON.stringify({
permissions: { allow: ['Bash'] },
hooks: { Stop: [appHook] },
}),
]);
expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({
permissions: { allow: ['Bash'] },
hooks: { Stop: [appHook] },
});
});
});