mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
* 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>
145 lines
5.7 KiB
TypeScript
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')
|
|
})
|
|
})
|