Files
windmill/frontend/src/lib/userDraftQuiesce.test.ts
T
Ruben FiszelandClaude Opus 5 d8ebbc06f5 fix: tell the user when a draft conflict has stopped their edits saving (#11241)
* fix: tell the user when a draft conflict has stopped their edits saving

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: give nothing up until the other version has actually loaded

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ignore a reload the editor has moved on from, and settle saves before resolving

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle the key before a forced resolution, and cover quiesce with tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let a resolution tell it belongs to an editor that is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: end a conflict resolution when the editing session does, not the component

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show the resource conflict in the fixed banner, and scope busy to its session

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the conflict alert in editors no host wraps

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: free a reopened editor from a resolution the closed one left in flight

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: make switching workspace-specific versions a new conflict session

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the local draft when a conflict resolution is abandoned

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let a newer session outrank a resolution that outlived its editor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: end a conflict resolution with the session that started it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reopen a conflicted editor on the version the server refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a refused draft deletion a deletion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ungate the editor when a resolution loads the other draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle the draft key before reading the version to load

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle the key again after reading, for edits made under the read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:39:49 +00:00

127 lines
5.2 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from 'vitest'
// Mocked so a test can hold a POST in flight: that window is the whole point of `quiesce`,
// which exists to wait one out rather than return while it is still going.
const updateDraft = vi.fn(async (..._args: any[]) => ({
status: 'saved' as const,
current_timestamp: '2020-01-01T00:00:00Z'
}))
vi.mock('./gen', () => ({
DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) }
}))
vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } }))
vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() }))
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
function deferred<T = void>() {
let resolve!: (v: T) => void
const promise = new Promise<T>((res) => (resolve = res))
return { promise, resolve }
}
afterEach(() => {
vi.clearAllMocks()
updateDraft.mockResolvedValue({ status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' })
})
/**
* Resolving a draft conflict installs a fresh baseline. Anything of the old version still in the
* air when that lands would be made acceptable by it, and a rejected one re-raises the conflict
* just resolved — so the resolution waits for the key to go quiet first. What it must NOT do is
* discard the payload while waiting: a refused save keeps it here, and until the resolution
* commits it is the only copy of the edit outside the editor's own memory.
*/
describe('UserDraftDbSyncer.quiesce', () => {
it('waits for a save already in flight', async () => {
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_a' }
const inFlight = deferred()
let settledBeforeQuiesceReturned = false
updateDraft.mockImplementationOnce(async () => {
await inFlight.promise
settledBeforeQuiesceReturned = true
return { status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' }
})
// In flight, not awaited: the POST is held open by the mock above.
const saving = UserDraftDbSyncer.save({ ...q, value: { v: 'in flight' }, immediate: true })
let quiesced = false
const quiescing = UserDraftDbSyncer.quiesce(q).then(() => (quiesced = true))
// A full macrotask, not a microtask: anything that merely yields would have resolved by
// now, so this is what tells "waited for the POST" apart from "waited for nothing".
await new Promise((r) => setTimeout(r, 0))
expect(quiesced).toBe(false)
inFlight.resolve()
await quiescing
await saving
expect(settledBeforeQuiesceReturned).toBe(true)
})
it('keeps a refused payload recoverable, until the resolution that replaces it says otherwise', async () => {
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_b' }
// Refused, so the payload stays parked — the state a conflict resolution starts from.
updateDraft.mockResolvedValueOnce({
status: 'conflict',
current_timestamp: '2020-01-02T00:00:00Z'
})
await UserDraftDbSyncer.save({ ...q, value: { v: 'refused' }, immediate: true })
expect(UserDraftDbSyncer.getConflict(q).conflict).toBeTruthy()
await UserDraftDbSyncer.quiesce(q)
// A resolution abandoned here (the editor closed while quiescing) must leave the edit
// somewhere it can still be sent. Flushing is how that payload is observed, since the
// parked opts themselves are private to the syncer.
updateDraft.mockClear()
await UserDraftDbSyncer.flush(q)
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(updateDraft.mock.calls[0][0]).toMatchObject({ requestBody: { value: { v: 'refused' } } })
// Only the caller that has something to put in its place drops it.
UserDraftDbSyncer.dropPending(q)
updateDraft.mockClear()
await UserDraftDbSyncer.flush(q)
expect(updateDraft).not.toHaveBeenCalled()
})
it('keeps a parked delete distinguishable from nothing parked', async () => {
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_delete' }
expect(UserDraftDbSyncer.peekPending(q)).toBeUndefined()
// Reverting to the deployed value, or discarding, parks a delete. Refused, it stays
// parked — and a resolution reading it has to keep meaning "remove the row", not fall
// back to whatever the server holds.
updateDraft.mockResolvedValueOnce({
status: 'conflict',
current_timestamp: '2020-01-02T00:00:00Z'
})
await UserDraftDbSyncer.save({ ...q, value: null, immediate: true })
expect(UserDraftDbSyncer.peekPending(q)).toEqual({ value: null })
})
it('cancels a debounced autosave so it cannot displace the write that follows', async () => {
const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_c' }
vi.useFakeTimers()
try {
// Debounced rather than immediate: this is the autosave a resolution has to call off, or
// it fires mid-resolution and, being conditional, is refused.
void UserDraftDbSyncer.save({ ...q, value: { v: 'queued' } })
await UserDraftDbSyncer.quiesce(q)
updateDraft.mockClear()
// Past the debouncer's 10s ceiling, so a schedule that survived has certainly fired.
// Real time would not reach it: the test would pass with the cancelling removed.
await vi.advanceTimersByTimeAsync(11000)
expect(updateDraft).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})