Files
windmill/frontend/src/lib/newDraftFlag.test.ts
T
GuilhemandClaude Opus 4.8 9705d60284 fix(frontend): keep session-exit URL clean by syncing new_draft strip with the router (#10101)
* fix(frontend): keep session-exit URL clean by syncing new_draft strip with the router

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

* chore(frontend): correct replaceState comment and test-mock wording per review

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(frontend): correct replaceState comment and drop drafting-history phrasing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:56:19 +02:00

145 lines
5.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
// Service layer mocked: a "saved" response drives the syncer's confirmed-save
// path, which is what `onSaved` / `stripNewDraftFlagOnSave` hang off of.
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: '' } }))
// `getLocalDraftHint` gates the seed branch; drive it per-test via `hints.value`.
const hints = vi.hoisted(() => ({ value: undefined as boolean | undefined }))
vi.mock('./localDraftHints.svelte', () => ({
setLocalDraftHint: vi.fn(),
getLocalDraftHint: () => hints.value
}))
// `stripNewDraftFlag` rewrites the URL through SvelteKit's `replaceState` and
// refreshes the session-switch's remembered nav route. Mock those so the strip
// is observable via `window.location.href` (mirroring jsdom) and the remembered
// route can be asserted.
const rememberNavRoute = vi.hoisted(() => vi.fn())
vi.mock('$app/navigation', () => ({
replaceState: (url: URL | string, _state: unknown) => {
window.location.href = new URL(url, window.location.href).toString()
}
}))
vi.mock('$app/state', () => ({ page: { state: {} } }))
vi.mock('$lib/components/sessions/sessionSwitch.svelte', () => ({ rememberNavRoute }))
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
import { stripNewDraftFlagOnSave, shouldSeedNewDraft } from './newDraftFlag'
/** Minimal `window` stand-in: `history.replaceState` rewrites `location.href`,
* mirroring what jsdom does, so the helper's strip is observable. */
function stubWindow(href: string): { current: () => string } {
const win: any = {
location: { href },
history: {
state: null as unknown,
replaceState(state: unknown, _title: string, url: string) {
this.state = state
win.location.href = new URL(url, win.location.href).toString()
}
}
}
vi.stubGlobal('window', win)
return { current: () => win.location.href }
}
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
hints.value = undefined
updateDraft.mockResolvedValue({ status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' })
})
describe('shouldSeedNewDraft', () => {
const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/draft_a' }
it('is false without the flag', () => {
expect(shouldSeedNewDraft(new URLSearchParams(''), q.workspace, q.itemKind, q.path)).toBe(false)
})
it('is true for a fresh, never-persisted new draft', () => {
hints.value = undefined
expect(
shouldSeedNewDraft(new URLSearchParams('new_draft=true'), q.workspace, q.itemKind, q.path)
).toBe(true)
})
it('is false once the draft is persisted this session (stale flag → load, not re-seed)', () => {
hints.value = true
expect(
shouldSeedNewDraft(new URLSearchParams('new_draft=true'), q.workspace, q.itemKind, q.path)
).toBe(false)
})
})
describe('UserDraftDbSyncer.onSaved', () => {
it('fires after a confirmed non-delete save', async () => {
const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/draft_a' }
const listener = vi.fn()
UserDraftDbSyncer.onSaved(q, listener)
await UserDraftDbSyncer.save({ ...q, value: { content: 'x' }, immediate: true })
expect(listener).toHaveBeenCalledTimes(1)
})
it('does NOT fire on a delete (value: null) save', async () => {
const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/draft_b' }
const listener = vi.fn()
UserDraftDbSyncer.onSaved(q, listener)
await UserDraftDbSyncer.save({ ...q, value: null, immediate: true })
expect(listener).not.toHaveBeenCalled()
})
it('stops firing after unsubscribe', async () => {
const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/draft_c' }
const listener = vi.fn()
const unsub = UserDraftDbSyncer.onSaved(q, listener)
unsub()
await UserDraftDbSyncer.save({ ...q, value: { content: 'x' }, immediate: true })
expect(listener).not.toHaveBeenCalled()
})
})
describe('stripNewDraftFlagOnSave', () => {
beforeEach(() => {
stubWindow('http://localhost/scripts/edit/u/me/draft_d?new_draft=true&template=foo')
})
it('keeps ?new_draft until a save lands, then strips only that flag', async () => {
const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/draft_d' }
stripNewDraftFlagOnSave(q)
// Before any save the flag is untouched.
expect(window.location.href).toContain('new_draft=true')
await UserDraftDbSyncer.save({ ...q, value: { content: 'x' }, immediate: true })
expect(window.location.href).not.toContain('new_draft')
// Sibling seeding params are preserved.
expect(window.location.href).toContain('template=foo')
// The remembered nav route is refreshed to the stripped URL so exiting an
// AI session returns here without re-adding ?new_draft.
expect(rememberNavRoute).toHaveBeenCalledWith('/scripts/edit/u/me/draft_d?template=foo')
})
it('does not strip on a delete save', async () => {
const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/draft_e' }
stubWindow('http://localhost/scripts/edit/u/me/draft_e?new_draft=true')
stripNewDraftFlagOnSave(q)
await UserDraftDbSyncer.save({ ...q, value: null, immediate: true })
expect(window.location.href).toContain('new_draft=true')
})
it('cleanup unsubscribes so a later save does not strip', async () => {
const q = { workspace: 'w', itemKind: 'script' as const, path: 'u/me/draft_f' }
stubWindow('http://localhost/scripts/edit/u/me/draft_f?new_draft=true')
const cleanup = stripNewDraftFlagOnSave(q)
cleanup()
await UserDraftDbSyncer.save({ ...q, value: { content: 'x' }, immediate: true })
expect(window.location.href).toContain('new_draft=true')
})
})