From 9d326358aef5d035a1c85beaa020e91f0075a9bc Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 17 May 2026 23:56:49 +0200 Subject: [PATCH] fix(frontend): trigger autosave no longer false-restores on plain open Co-Authored-By: Claude Opus 4.7 --- .../azure/AzureTriggerEditorInner.svelte | 7 +- .../email/EmailTriggerEditorInner.svelte | 6 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 6 +- .../triggers/http/RouteEditorInner.svelte | 6 +- .../kafka/KafkaTriggerEditorInner.svelte | 6 +- .../mqtt/MqttTriggerEditorInner.svelte | 6 +- .../nats/NatsTriggerEditorInner.svelte | 6 +- .../PostgresTriggerEditorInner.svelte | 7 +- .../schedules/ScheduleEditorInner.svelte | 7 +- .../triggers/sqs/SqsTriggerEditorInner.svelte | 6 +- .../WebsocketTriggerEditorInner.svelte | 6 +- frontend/src/lib/userDraft.svelte.ts | 72 +++++++++++++++++ frontend/src/lib/userDraft.test.ts | 81 ++++++++++++++++++- 13 files changed, 187 insertions(+), 35 deletions(-) diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 967b7caca2..fae0bb117b 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -25,7 +25,7 @@ import { saveAzureTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -131,7 +131,7 @@ } originalConfig = structuredClone($state.snapshot(getAzureConfig())) const localCfg = UserDraft.get>('trigger_azure', ePath) - if (localCfg && !deepEqual(localCfg, getAzureConfig())) { + if (localDraftDiffers(localCfg, getAzureConfig())) { const deployedCfg = structuredClone($state.snapshot(getAzureConfig())) await loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -314,7 +314,8 @@ $effect(() => { if (drawerLoading || !initialPath) return - azureConfig && UserDraft.save('trigger_azure', initialPath, azureConfig) + azureConfig && + UserDraft.saveIfChanged('trigger_azure', initialPath, azureConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 4cc7942cb2..6ddbfbca22 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -29,7 +29,7 @@ import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { saveEmailTriggerFromCfg } from './utils' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -128,7 +128,7 @@ } originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) const localCfg = UserDraft.get>('trigger_email', ePath) - if (localCfg && !deepEqual(localCfg, getEmailTriggerConfig())) { + if (localDraftDiffers(localCfg, getEmailTriggerConfig())) { const deployedCfg = structuredClone($state.snapshot(getEmailTriggerConfig())) loadTriggerConfig(localCfg as Partial) notifyRestoredFromLocal(false, true, { @@ -311,7 +311,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_email', initialPath, emailConfig) + UserDraft.saveIfChanged('trigger_email', initialPath, emailConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 261bbfc71a..a9d5579768 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -27,7 +27,7 @@ import { saveGcpTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -133,7 +133,7 @@ } originalConfig = structuredClone($state.snapshot(getGcpConfig())) const localCfg = UserDraft.get>('trigger_gcp', ePath) - if (localCfg && !deepEqual(localCfg, getGcpConfig())) { + if (localDraftDiffers(localCfg, getGcpConfig())) { const deployedCfg = structuredClone($state.snapshot(getGcpConfig())) await loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -331,7 +331,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - gcpConfig && UserDraft.save('trigger_gcp', initialPath, gcpConfig) + gcpConfig && UserDraft.saveIfChanged('trigger_gcp', initialPath, gcpConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 1bbaa6ae5f..2aa1ff22e0 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -55,7 +55,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -227,7 +227,7 @@ } originalConfig = structuredClone($state.snapshot(getRouteConfig())) const localCfg = UserDraft.get>('trigger_http', ePath) - if (localCfg && !deepEqual(localCfg, getRouteConfig())) { + if (localDraftDiffers(localCfg, getRouteConfig())) { const deployedCfg = structuredClone($state.snapshot(getRouteConfig())) loadTriggerConfig(localCfg as Partial) notifyRestoredFromLocal(false, true, { @@ -465,7 +465,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_http', initialPath, routeConfig) + UserDraft.saveIfChanged('trigger_http', initialPath, routeConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index b0af3e146c..6f1f10029c 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -24,7 +24,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -155,7 +155,7 @@ } originalConfig = structuredClone($state.snapshot(getSaveCfg())) const localCfg = UserDraft.get>('trigger_kafka', ePath) - if (localCfg && !deepEqual(localCfg, getSaveCfg())) { + if (localDraftDiffers(localCfg, getSaveCfg())) { const deployedCfg = structuredClone($state.snapshot(getSaveCfg())) loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -369,7 +369,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_kafka', initialPath, kafkaConfig) + UserDraft.saveIfChanged('trigger_kafka', initialPath, kafkaConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 86144722cd..7daea4f8d1 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -39,7 +39,7 @@ import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' interface Props { @@ -151,7 +151,7 @@ } originalConfig = structuredClone($state.snapshot(getSaveCfg())) const localCfg = UserDraft.get>('trigger_mqtt', ePath) - if (localCfg && !deepEqual(localCfg, getSaveCfg())) { + if (localDraftDiffers(localCfg, getSaveCfg())) { const deployedCfg = structuredClone($state.snapshot(getSaveCfg())) await loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -355,7 +355,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_mqtt', initialPath, mqttConfig) + UserDraft.saveIfChanged('trigger_mqtt', initialPath, mqttConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 1fdaa0f4fd..6091a6c375 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -23,7 +23,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -139,7 +139,7 @@ } originalConfig = structuredClone($state.snapshot(getSaveCfg())) const localCfg = UserDraft.get>('trigger_nats', ePath) - if (localCfg && !deepEqual(localCfg, getSaveCfg())) { + if (localDraftDiffers(localCfg, getSaveCfg())) { const deployedCfg = structuredClone($state.snapshot(getSaveCfg())) await loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -339,7 +339,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_nats', initialPath, natsConfig) + UserDraft.saveIfChanged('trigger_nats', initialPath, natsConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 948295f635..ece23855a6 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -45,7 +45,7 @@ import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import { capitalize } from '$lib/utils' @@ -246,7 +246,7 @@ } originalConfig = structuredClone($state.snapshot(getSaveCfg())) const localCfg = UserDraft.get>('trigger_postgres', ePath) - if (localCfg && !deepEqual(localCfg, getSaveCfg())) { + if (localDraftDiffers(localCfg, getSaveCfg())) { const deployedCfg = structuredClone($state.snapshot(getSaveCfg())) await loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -496,7 +496,8 @@ $effect(() => { if (drawerLoading || !initialPath) return - postgresConfig && UserDraft.save('trigger_postgres', initialPath, postgresConfig) + postgresConfig && + UserDraft.saveIfChanged('trigger_postgres', initialPath, postgresConfig, originalConfig) }) $effect(() => { diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index ad2ff9bcbc..8e1b77706e 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -38,8 +38,7 @@ import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' - import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TextInput from '$lib/components/text_input/TextInput.svelte' import { twMerge } from 'tailwind-merge' @@ -149,7 +148,7 @@ initialConfig = structuredClone($state.snapshot(getScheduleCfg())) } const localCfg = UserDraft.get>('trigger_schedule', ePath) - if (localCfg && !deepEqual(localCfg, getScheduleCfg())) { + if (localDraftDiffers(localCfg, getScheduleCfg())) { // Snapshot the just-loaded backend config so "Reset to // deployed" can re-apply it, then overlay the local autosave. const deployedCfg = structuredClone($state.snapshot(getScheduleCfg())) @@ -675,7 +674,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_schedule', initialPath, scheduleCfg) + UserDraft.saveIfChanged('trigger_schedule', initialPath, scheduleCfg, initialConfig) }) diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index f7e05305b4..253e7c075c 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -31,7 +31,7 @@ import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' interface Props { @@ -137,7 +137,7 @@ } originalConfig = structuredClone($state.snapshot(getSaveCfg())) const localCfg = UserDraft.get>('trigger_sqs', ePath) - if (localCfg && !deepEqual(localCfg, getSaveCfg())) { + if (localDraftDiffers(localCfg, getSaveCfg())) { const deployedCfg = structuredClone($state.snapshot(getSaveCfg())) loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -332,7 +332,7 @@ // a path (no localStorage write would happen anyway). $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_sqs', initialPath, sqsConfig) + UserDraft.saveIfChanged('trigger_sqs', initialPath, sqsConfig, originalConfig) }) diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index 568eb9606d..37a752c7bd 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -42,7 +42,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -183,7 +183,7 @@ } originalConfig = structuredClone($state.snapshot(getSaveCfg())) const localCfg = UserDraft.get>('trigger_websocket', ePath) - if (localCfg && !deepEqual(localCfg, getSaveCfg())) { + if (localDraftDiffers(localCfg, getSaveCfg())) { const deployedCfg = structuredClone($state.snapshot(getSaveCfg())) loadTriggerConfig(localCfg) notifyRestoredFromLocal(false, true, { @@ -423,7 +423,7 @@ $effect(() => { if (drawerLoading || !initialPath) return - UserDraft.save('trigger_websocket', initialPath, websocketCfg) + UserDraft.saveIfChanged('trigger_websocket', initialPath, websocketCfg, originalConfig) }) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index fc1f2d7214..78db410a50 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -1,5 +1,6 @@ import { get } from 'svelte/store' import { onDestroy, untrack } from 'svelte' +import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' import { useLocalStorageValue } from './svelte5Utils.svelte' @@ -247,6 +248,50 @@ export type UserDraftHandle = { setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void } +/** + * JSON round-trip normalization. localStorage persistence stringifies the + * draft, which silently drops keys whose value is `undefined`, turns `Date` + * into a string, etc. A freshly-built config object (e.g. a trigger editor's + * `getXConfig()`) keeps those `undefined`-valued keys, so a raw + * `deepEqual(persistedDraft, freshConfig)` reports spurious differences + * (`{ a: undefined }` ≠ `{}`). Normalize BOTH sides through the same + * round-trip before comparing. Returns the input unchanged if it can't be + * serialized (e.g. a cyclic structure) — better a false "differs" than a + * throw inside a load/effect path. + */ +export function normalizeForCompare(value: V | undefined): V | undefined { + if (value === undefined) return undefined + try { + return JSON.parse(JSON.stringify(value)) as V + } catch { + return value + } +} + +/** + * Whether the persisted local autosave (`localDraft`, as returned by + * `UserDraft.get`) meaningfully differs from the freshly-built + * `currentConfig`. Editor restore guards use this to decide whether to + * overlay the local autosave and toast. + * + * Returns `false` when there is no local draft. Normalizes both sides (see + * `normalizeForCompare`) so a draft that round-trips equal to the deployed + * config — e.g. one written by merely opening then closing the editor with + * no edits — is correctly treated as "no meaningful draft" instead of + * spuriously triggering a restore on every reopen. + * + * Typed as a guard: a `true` result narrows `localDraft` to non-nullish + * `V`, mirroring the `localCfg && …` narrowing it replaces so call sites + * can pass the draft straight into `loadXConfig(...)` without re-checking. + */ +export function localDraftDiffers( + localDraft: V | undefined | null, + currentConfig: V +): localDraft is V { + if (localDraft === undefined || localDraft === null) return false + return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig)) +} + export const UserDraft = { save(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) @@ -277,6 +322,33 @@ export const UserDraft = { } }, + /** + * Autosave gate. Persists `value` as the local draft only when it + * differs (after `normalizeForCompare`) from `deployed` — the backend + * baseline the editor loaded and keeps in sync across saves (the same + * value the editor's own `hasChanged` / dirty signal compares against). + * When equal, removes any existing draft instead. + * + * Without this, an editor's autosave `$effect` fires once as soon as it + * finishes loading (the `drawerLoading` flag flips and the just-loaded + * config counts as a "change" to the effect), so merely opening and + * closing an editor with no edits would leave a no-op draft behind that + * `has()` / restore guards then treat as unsaved work forever. + */ + saveIfChanged( + itemKind: UserDraftItemKind, + path: string, + value: V, + deployed: V | undefined, + opts?: UserDraftOptions + ): void { + if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) { + UserDraft.remove(itemKind, path, opts) + } else { + UserDraft.save(itemKind, path, value, opts) + } + }, + get( itemKind: UserDraftItemKind, path: string, diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index ae33c31587..90d25000e9 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -15,7 +15,8 @@ vi.mock('svelte', async (importOriginal) => { }) // Imported AFTER vi.mock so the module sees the mocked onDestroy. -const { UserDraft, __resetUserDraftForTesting } = await import('./userDraft.svelte') +const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } = + await import('./userDraft.svelte') const { workspaceStore } = await import('./stores') function flushDestroyCallbacks(): void { @@ -640,3 +641,81 @@ describe('gcUserDrafts', () => { expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull() }) }) + +describe('normalizeForCompare', () => { + it('returns undefined for undefined input', () => { + expect(normalizeForCompare(undefined)).toBeUndefined() + }) + + it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => { + const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } }) + expect(out).toEqual({ a: 1, c: { e: 2 } }) + expect(Object.keys(out as object)).not.toContain('b') + expect(Object.keys((out as any).c)).not.toContain('d') + }) + + it('falls back to the original value when not serializable (cyclic)', () => { + const cyclic: any = { a: 1 } + cyclic.self = cyclic + expect(normalizeForCompare(cyclic)).toBe(cyclic) + }) +}) + +describe('localDraftDiffers', () => { + it('returns false when there is no local draft', () => { + expect(localDraftDiffers(undefined, { a: 1 })).toBe(false) + expect(localDraftDiffers(null, { a: 1 })).toBe(false) + }) + + it('treats a draft that round-trips equal to the config as NOT differing', () => { + // The Schedule bug: getXCfg() emits conditionally-undefined keys, but + // the persisted draft went through JSON.stringify which dropped them. + const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } + const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule } + expect(localDraftDiffers(persisted, freshCfg)).toBe(false) + }) + + it('returns true for a genuine difference', () => { + expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true) + expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true) + }) +}) + +describe('UserDraft.saveIfChanged', () => { + const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s' + + it('does not persist a draft equal to the deployed baseline', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } + // value is the post-load reactive cfg — same shape, undefined keys present + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('treats a value that round-trips equal to deployed as unchanged', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } + const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule } + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('persists when the value differs from the deployed baseline', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } + const value = { path: 'u/me/s', schedule: '5 0 * * *' } + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) + expect(storedShape(KEY)).toBe(wrapped(value)) + }) + + it('removes a pre-existing draft once the value reverts to deployed', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } + UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' }) + expect(localStorage.getItem(KEY)).not.toBeNull() + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('persists when there is no deployed baseline (undefined)', () => { + const value = { path: 'u/me/s', schedule: '0 0 * * *' } + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined) + expect(storedShape(KEY)).toBe(wrapped(value)) + }) +})