diff --git a/frontend/src/lib/components/triggers/schedules/scheduleCfg.test.ts b/frontend/src/lib/components/triggers/schedules/scheduleCfg.test.ts new file mode 100644 index 0000000000..2c125e1293 --- /dev/null +++ b/frontend/src/lib/components/triggers/schedules/scheduleCfg.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { normalizeScheduleCfg, scheduleCfgOf, scheduleFormOf } from './scheduleCfg' + +// A deployed schedule as the API returns it: server fields the form never shows, a short cron. +const deployed = { + path: 'u/me/s', + schedule: '0 0 12 * *', + timezone: 'UTC', + script_path: 'u/me/script', + is_flow: false, + args: { x: 'a' }, + enabled: true, + on_failure: 'script/hub/1/workspace-or-schedule-error-handler-slack', + on_failure_extra_args: { channel: '#alerts' }, + on_recovery: null, + summary: '', + extra_perms: {}, + edited_by: 'me', + edited_at: '2026-09-11T00:00:00Z', + workspace_id: 'w', + email: 'me@example.com' +} + +describe('schedule config normalization', () => { + // The deployed side is kept in this shape and compared with what the form hands back: if the + // two drift, a schedule reads as edited the moment it is opened. + it('is what the form hands back after loading it, and idempotent', () => { + const once = normalizeScheduleCfg(deployed) + expect(scheduleCfgOf(scheduleFormOf(once))).toEqual(once) + expect(JSON.stringify(normalizeScheduleCfg(once))).toBe(JSON.stringify(once)) + expect(once).toMatchObject({ + schedule: '0 0 12 * * *', + on_failure: 'script/hub/1/workspace-or-schedule-error-handler-slack', + on_failure_extra_args: { channel: '#alerts' }, + on_recovery: undefined, + summary: undefined + }) + expect(once).not.toHaveProperty('edited_by') + }) +}) diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index 9d5e3db518..220183ac82 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -162,6 +162,37 @@ describe('item store: commands', () => { expect(a.writes).toHaveLength(1) }) + it('saves items in turn, all busy from the call, and stops at the first failure', async () => { + const rows = fakeRows() + const store = createItemStore(rows.port) + const written: string[] = [] + const a = adapter({ deployed: deployedRes }, async (ctx) => { + written.push(ctx.workspace) + if (ctx.workspace === 'w1') throw new Error('refused') + }) + const acquire = (ws: string) => + store.acquire( + { workspace: ws, kind: 'resource', path: 'u/me/r' }, + { workspace: ws, path: 'u/me/r' }, + a + ).handle + const [first, second] = [acquire('w1'), acquire('w2')] + await settle() + first.value = { ...deployedRes, description: 'one' } + second.value = { ...deployedRes, description: 'two' } + + const saving = store.saveEach([first, second]) + expect([first.busy, second.busy]).toEqual([true, true]) + const outcomes = await saving + + expect(outcomes).toEqual([ + { ok: false, error: 'refused' }, + { ok: false, error: 'Not saved', skipped: true } + ]) + expect(written).toEqual(['w1']) + expect([first.dirty, second.dirty, second.busy]).toEqual([true, true, false]) + }) + it('holds a toggle behind a save, and keeps an unrelated edit through the toggle', async () => { type Sched = { path: string; enabled: boolean; summary: string } const rows = fakeRows()