Files
windmill/frontend/src/lib/components/sessions/sessionState.test.ts
T

659 lines
23 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { get } from 'svelte/store'
import {
commitSessionWorkspace,
createSession,
decideSessionLifecycle,
findEmptyLandingSession,
isForkSession,
isTearingDownOpenSession,
renameSession,
sessionInCurrentFamily,
setGeneratedSessionSummary,
adoptRemoteRow,
setSessionDraftPrompt,
sessionState,
withOpenSessionTeardown,
type Session
} from './sessionState.svelte'
import {
clearSessionRecovered,
isSessionRecovered,
markSessionRecovered
} from './sessionRecoveryNotice.svelte'
import {
enterpriseLicense,
usersWorkspaceStore,
workspaceStore,
type UserWorkspace
} from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
// Force createWorkspaceFork to fail so we can pin commitSessionWorkspace's
// failure contract (the invariant the beforeSend abort fix relies on).
vi.mock('$lib/gen', async (orig) => {
const actual = await orig<typeof import('$lib/gen')>()
return {
...actual,
WorkspaceService: {
...actual.WorkspaceService,
createWorkspaceFork: vi.fn().mockRejectedValue(new Error('fork creation failed')),
listUserWorkspaces: vi.fn().mockResolvedValue([])
}
}
})
// Minimal fixtures — only the fields these pure helpers read matter; the rest
// is filled via cast so we don't track unrelated schema churn.
function session(over: Partial<Session> = {}): Session {
return { id: 's1', name: 'sess', createdAt: 0, ...over }
}
function ws(id: string, parent?: string): UserWorkspace {
return { id, name: id, parent_workspace_id: parent } as unknown as UserWorkspace
}
describe('isForkSession', () => {
it('is false for a draft with no workspace at all', () => {
expect(isForkSession(session(), [])).toBe(false)
})
it('is false when the workspace is a (non-fork) root', () => {
expect(isForkSession(session({ workspace_id: 'root' }), [ws('root')])).toBe(false)
})
it('is true when the workspace has a parent (is a fork)', () => {
expect(isForkSession(session({ workspace_id: 'fork' }), [ws('fork', 'root')])).toBe(true)
})
it('treats a committed-but-missing workspace as a fork (terminal unavailable state)', () => {
expect(isForkSession(session({ workspace_id: 'gone' }), [])).toBe(true)
})
it('is false for a draft whose pending workspace is missing', () => {
expect(isForkSession(session({ pending_workspace_id: 'gone' }), [])).toBe(false)
})
it('resolves a draft via its pending workspace', () => {
expect(isForkSession(session({ pending_workspace_id: 'fork' }), [ws('fork', 'root')])).toBe(
true
)
})
})
describe('commitSessionWorkspace — fork-creation failure', () => {
it('returns undefined and drops pending_fork (so beforeSend aborts the send)', async () => {
const id = 'test-commit-fork-fail'
// Forking is licensed here so the commit reaches materializeFork (which is
// mocked to fail); the unlicensed path is covered separately below.
const prevLicense = get(enterpriseLicense)
enterpriseLicense.set('test-license')
sessionState.sessions.push({
id,
name: 'fork-fail',
createdAt: 0,
pending_fork: { parent_workspace_id: 'parent_ws', id: 'wm-fork-nope', name: 'nope' }
} as Session)
try {
const committed = await commitSessionWorkspace(id, 'parent_ws')
// Not committed → undefined. This is what makes beforeSend throw rather
// than letting the first message ship to the parent workspace.
expect(committed).toBeUndefined()
const s = sessionState.sessions.find((x) => x.id === id)
expect(s?.workspace_id).toBeUndefined()
expect(s?.pending_fork).toBeUndefined()
} finally {
const i = sessionState.sessions.findIndex((x) => x.id === id)
if (i >= 0) sessionState.sessions.splice(i, 1)
enterpriseLicense.set(prevLicense)
}
})
})
// Set the workspace list to a given number of non-'admins' workspaces so the
// commit-path's CE workspace-cap check (mirror of backend _check_nb_of_workspaces)
// can be exercised. Returns a restore fn.
function withNonAdminWorkspaces(count: number): () => void {
const prev = get(usersWorkspaceStore)
const workspaces = Array.from({ length: count }, (_, i) => ws(`ws_${i}`))
usersWorkspaceStore.set({ email: 't@t', workspaces } as never)
return () => usersWorkspaceStore.set(prev)
}
describe('commitSessionWorkspace — CE workspace-cap fork guard', () => {
it('throws and never calls createWorkspaceFork when the CE workspace cap is reached', async () => {
const id = 'test-commit-fork-capped'
const prevLicense = get(enterpriseLicense)
enterpriseLicense.set(undefined)
// At/above the cap (2 non-'admins' workspaces) the backend would reject, so
// the client guard blocks the commit before materializeFork.
const restoreWs = withNonAdminWorkspaces(2)
vi.mocked(WorkspaceService.createWorkspaceFork).mockClear()
sessionState.sessions.push({
id,
name: 'fork-capped',
createdAt: 0,
pending_fork: { parent_workspace_id: 'parent_ws', id: 'wm-fork-capped', name: 'capped' }
} as Session)
try {
await expect(commitSessionWorkspace(id, 'parent_ws')).rejects.toThrow(/limited to/)
expect(WorkspaceService.createWorkspaceFork).not.toHaveBeenCalled()
// pending_fork is preserved so the block persists until the user picks a
// non-fork workspace (which clears it via setSessionPendingWorkspace).
const s = sessionState.sessions.find((x) => x.id === id)
expect(s?.pending_fork).toBeDefined()
expect(s?.workspace_id).toBeUndefined()
} finally {
const i = sessionState.sessions.findIndex((x) => x.id === id)
if (i >= 0) sessionState.sessions.splice(i, 1)
restoreWs()
enterpriseLicense.set(prevLicense)
}
})
it('lets an unlicensed user under the cap proceed to createWorkspaceFork', async () => {
const id = 'test-commit-fork-under-cap'
const prevLicense = get(enterpriseLicense)
enterpriseLicense.set(undefined)
// Below the cap (1 non-'admins' workspace) CE still allows a fork — the
// guard must NOT fire. createWorkspaceFork is mocked to reject, so the
// commit falls through to the failure contract (undefined, pending dropped).
const restoreWs = withNonAdminWorkspaces(1)
vi.mocked(WorkspaceService.createWorkspaceFork).mockClear()
sessionState.sessions.push({
id,
name: 'fork-under-cap',
createdAt: 0,
pending_fork: { parent_workspace_id: 'parent_ws', id: 'wm-fork-ok', name: 'ok' }
} as Session)
try {
const committed = await commitSessionWorkspace(id, 'parent_ws')
expect(committed).toBeUndefined()
expect(WorkspaceService.createWorkspaceFork).toHaveBeenCalled()
const s = sessionState.sessions.find((x) => x.id === id)
expect(s?.pending_fork).toBeUndefined()
} finally {
const i = sessionState.sessions.findIndex((x) => x.id === id)
if (i >= 0) sessionState.sessions.splice(i, 1)
restoreWs()
enterpriseLicense.set(prevLicense)
}
})
})
describe('commitSessionWorkspace — leaves workspaceStore untouched', () => {
it('commits the session workspace without switching the active workspaceStore', async () => {
// A session chat targets its committed workspace through the manager's
// workspace resolver (AIChatManager.operatingWorkspace), so committing must
// NOT yank the user's active (navigation-mode) workspace — even when the
// committed workspace and the active one differ.
const id = 'test-commit-ws-sync'
const prev = get(workspaceStore)
workspaceStore.set('wm-fork-x')
sessionState.sessions.push({
id,
name: 'ws-sync',
createdAt: 0,
pending_workspace_id: 'root_ws'
} as Session)
try {
const committed = await commitSessionWorkspace(id, undefined)
expect(committed).toBe('root_ws')
const s = sessionState.sessions.find((x) => x.id === id)
expect(s?.workspace_id).toBe('root_ws')
expect(s?.workspace_root_id).toBe('root_ws')
expect(s?.pending_workspace_id).toBeUndefined()
// The active workspace is intentionally left where the user put it.
expect(get(workspaceStore)).toBe('wm-fork-x')
} finally {
const i = sessionState.sessions.findIndex((x) => x.id === id)
if (i >= 0) sessionState.sessions.splice(i, 1)
workspaceStore.set(prev)
}
})
})
describe('session summary generation guards', () => {
it('applies a generated summary only while the placeholder is still untouched', () => {
const id = 'test-generated-summary'
sessionState.sessions.push({
id,
name: 'generated-summary',
createdAt: 0,
chatId: 'chat-1',
summary: 'Bright session',
summarySource: 'placeholder'
} as Session)
try {
expect(setGeneratedSessionSummary(id, 'Build invoice workflow', 'chat-1')).toBe(true)
const s = sessionState.sessions.find((x) => x.id === id)
expect(s?.summary).toBe('Build invoice workflow')
expect(s?.summarySource).toBe('generated')
} finally {
const i = sessionState.sessions.findIndex((x) => x.id === id)
if (i >= 0) sessionState.sessions.splice(i, 1)
}
})
it('does not overwrite a manual rename or a different chat id', () => {
const id = 'test-generated-summary-manual'
sessionState.sessions.push({
id,
name: 'generated-summary-manual',
createdAt: 0,
chatId: 'chat-1',
summary: 'Bright session',
summarySource: 'placeholder'
} as Session)
try {
expect(setGeneratedSessionSummary(id, 'Wrong chat title', 'chat-2')).toBe(false)
renameSession(id, 'My chosen title')
expect(setGeneratedSessionSummary(id, 'Generated title', 'chat-1')).toBe(false)
const s = sessionState.sessions.find((x) => x.id === id)
expect(s?.summary).toBe('My chosen title')
expect(s?.summarySource).toBe('manual')
} finally {
const i = sessionState.sessions.findIndex((x) => x.id === id)
if (i >= 0) sessionState.sessions.splice(i, 1)
}
})
})
describe('decideSessionLifecycle — the never-orphaned rule (pure)', () => {
const mk = (over: Partial<Session> = {}): Session => ({
id: 'x',
name: 'session-1',
workspace_id: 'ws',
createdAt: 0,
...over
})
it('deleted workspace → delete, regardless of archive state', () => {
expect(decideSessionLifecycle(mk(), 'deleted')).toEqual({ action: 'delete' })
expect(decideSessionLifecycle(mk({ archived: true }), 'deleted')).toEqual({ action: 'delete' })
})
it('archived workspace → archive (tagged) when not already archived; no-op otherwise', () => {
expect(decideSessionLifecycle(mk(), 'archived')).toEqual({
action: 'archive',
patch: { archived: true, archivedByWorkspace: true }
})
expect(decideSessionLifecycle(mk({ archived: true }), 'archived')).toEqual({ action: 'noop' })
})
it('active workspace → unarchive only the ones WE archived (archivedByWorkspace)', () => {
expect(
decideSessionLifecycle(mk({ archived: true, archivedByWorkspace: true }), 'active')
).toEqual({
action: 'unarchive',
patch: { archived: undefined, archivedByWorkspace: undefined }
})
// user-archived (no archivedByWorkspace flag) is left alone
expect(decideSessionLifecycle(mk({ archived: true }), 'active')).toEqual({ action: 'noop' })
expect(decideSessionLifecycle(mk(), 'active')).toEqual({ action: 'noop' })
})
it('unknown status (workspace absent from the queried set) → no-op, never a delete', () => {
expect(decideSessionLifecycle(mk(), undefined)).toEqual({ action: 'noop' })
})
})
// Two-family fixture: rootA (with forkA) and rootB. Returns a restore fn.
function withTwoFamilies(activeWorkspace: string): () => void {
const prevUsers = get(usersWorkspaceStore)
const prevWs = get(workspaceStore)
usersWorkspaceStore.set({
email: 't@t',
workspaces: [ws('rootA'), ws('forkA', 'rootA'), ws('rootB')]
} as never)
workspaceStore.set(activeWorkspace)
return () => {
usersWorkspaceStore.set(prevUsers)
workspaceStore.set(prevWs)
}
}
describe('sessionInCurrentFamily', () => {
it('matches sessions bound anywhere in the active family, rejects other families', () => {
const restore = withTwoFamilies('forkA')
try {
expect(sessionInCurrentFamily(session({ workspace_id: 'rootA' }))).toBe(true)
expect(sessionInCurrentFamily(session({ pending_workspace_id: 'forkA' }))).toBe(true)
expect(sessionInCurrentFamily(session({ workspace_id: 'rootB' }))).toBe(false)
} finally {
restore()
}
})
it('fails open only for unbound transient drafts, closed for unbound persisted sessions', () => {
const restore = withTwoFamilies('rootA')
try {
// In-memory draft with no workspace picked yet — follows the user.
expect(sessionInCurrentFamily(session({ transient: true }))).toBe(true)
// Persisted session with no workspace binding (legacy data) must not
// surface in every family.
expect(sessionInCurrentFamily(session())).toBe(false)
} finally {
restore()
}
})
it('fails open when there is no active root', () => {
const restore = withTwoFamilies('rootA')
try {
workspaceStore.set(undefined)
expect(sessionInCurrentFamily(session({ workspace_id: 'rootB' }))).toBe(true)
} finally {
restore()
}
})
})
describe('createSession — reuses an untouched draft, family-scoped', () => {
it('reuses an untouched (transient) draft from the active family', () => {
const restore = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
const untouched = session({
id: 'untouched-same-family',
name: 'session-901',
pending_workspace_id: 'forkA',
transient: true
})
sessionState.sessions.push(untouched)
try {
const created = createSession()
// No new entry piled up: `+` switched back to the pristine draft.
expect(created.id).toBe('untouched-same-family')
expect(sessionState.currentSessionId).toBe('untouched-same-family')
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'untouched-same-family')
sessionState.currentSessionId = prevCurrent
restore()
}
})
it('clears the recovery notice off the draft it reuses, so `+` is not answered with "not found"', () => {
const restore = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
const landed = session({
id: 'recovered-blank',
name: 'session-903',
pending_workspace_id: 'forkA',
transient: true
})
sessionState.sessions.push(landed)
markSessionRecovered(landed.id)
try {
expect(createSession().id).toBe('recovered-blank')
expect(isSessionRecovered('recovered-blank')).toBe(false)
} finally {
clearSessionRecovered('recovered-blank')
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'recovered-blank')
sessionState.currentSessionId = prevCurrent
restore()
}
})
it('drops an untouched draft left over from another family and starts in the active workspace', () => {
const restore = withTwoFamilies('rootB')
const prevCurrent = sessionState.currentSessionId
const stale = session({
id: 'untouched-other-family',
name: 'session-902',
pending_workspace_id: 'forkA',
transient: true
})
sessionState.sessions.push(stale)
let createdId: string | undefined
try {
const created = createSession()
createdId = created.id
expect(created.id).not.toBe('untouched-other-family')
expect(created.pending_workspace_id).toBe('rootB')
expect(sessionState.sessions.some((s) => s.id === 'untouched-other-family')).toBe(false)
} finally {
sessionState.sessions = sessionState.sessions.filter(
(s) => s.id !== 'untouched-other-family' && s.id !== createdId
)
sessionState.currentSessionId = prevCurrent
restore()
}
})
it('does not reuse a touched (persisted) pending session — those spawn a fresh draft', () => {
const restore = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
// Touched pending session: persisted (no transient flag), same family.
const touched = session({
id: 'touched-same-family',
name: 'session-903',
pending_workspace_id: 'rootA',
draftPrompt: 'already typed'
})
sessionState.sessions.push(touched)
let createdId: string | undefined
try {
const created = createSession()
createdId = created.id
expect(created.id).not.toBe('touched-same-family')
expect(created.transient).toBe(true)
// Both coexist: a touched draft stays put, the new blank is its own entry.
expect(sessionState.sessions.some((s) => s.id === 'touched-same-family')).toBe(true)
} finally {
sessionState.sessions = sessionState.sessions.filter(
(s) => s.id !== 'touched-same-family' && s.id !== createdId
)
sessionState.currentSessionId = prevCurrent
restore()
}
})
it('stops reusing a draft the instant it is typed into, before the debounce flush', () => {
// The real transition (not a hand-built flag): a keystroke sets draftPrompt
// while the write is debounced. The draft stays transient (survives hydration)
// but is no longer a reusable blank, so `+` within the window spawns a second
// session and must not discard the typed draft.
vi.useFakeTimers()
const restore = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
const draft = session({
id: 'typed-within-window',
name: 'session-904',
pending_workspace_id: 'rootA',
transient: true
})
sessionState.sessions.push(draft)
let createdId: string | undefined
try {
setSessionDraftPrompt('typed-within-window', 'h')
// Still transient (persistence deferred), but now carries typed text.
expect(draft.transient).toBe(true)
expect(draft.draftPrompt).toBe('h')
const created = createSession()
createdId = created.id
expect(created.id).not.toBe('typed-within-window')
expect(created.transient).toBe(true)
// The typed draft survives the non-reuse drop as its own entry.
expect(sessionState.sessions.some((s) => s.id === 'typed-within-window')).toBe(true)
} finally {
vi.clearAllTimers()
vi.useRealTimers()
sessionState.sessions = sessionState.sessions.filter(
(s) => s.id !== 'typed-within-window' && s.id !== createdId
)
sessionState.currentSessionId = prevCurrent
restore()
}
})
it('does not reuse a draft typed into then erased back to empty (flush still pending)', () => {
// draftPrompt is '' here, not undefined: the user edited it (a flush is pending),
// so it must stay a real session — reusing/dropping it would be inconsistent
// across the 400ms boundary and could resurrect it via the pending timer.
vi.useFakeTimers()
const restore = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
const draft = session({
id: 'typed-then-erased',
name: 'session-905',
pending_workspace_id: 'rootA',
transient: true
})
sessionState.sessions.push(draft)
let createdId: string | undefined
try {
setSessionDraftPrompt('typed-then-erased', 'h')
setSessionDraftPrompt('typed-then-erased', '')
expect(draft.draftPrompt).toBe('')
const created = createSession()
createdId = created.id
expect(created.id).not.toBe('typed-then-erased')
expect(sessionState.sessions.some((s) => s.id === 'typed-then-erased')).toBe(true)
} finally {
vi.clearAllTimers()
vi.useRealTimers()
sessionState.sessions = sessionState.sessions.filter(
(s) => s.id !== 'typed-then-erased' && s.id !== createdId
)
sessionState.currentSessionId = prevCurrent
restore()
}
})
})
describe('adoptRemoteRow', () => {
it('clears what the stored row no longer carries, keeping the object identity', () => {
const held = {
id: 's1',
name: 's1',
createdAt: 1,
archived: true,
archivedByWorkspace: true
} as Session
// The other tab unarchived, so the row simply lacks the key.
const row = { id: 's1', name: 's1', createdAt: 1 } as Session
adoptRemoteRow(held, row)
expect('archived' in held).toBe(false)
expect('archivedByWorkspace' in held).toBe(false)
})
it('keeps the live editor stamps the store never holds', () => {
const held = {
id: 's1',
name: 's1',
createdAt: 1,
previewTabs: [
{ id: 't1', url: '/u', loc: '/u', friendlyLabel: 'My script', friendlyPath: 'f/a/b' }
]
} as Session
// Stored tabs carry no stamps — they are recomputed on mount — so taking the
// row's tabs wholesale drops a watching tab's labels back to raw paths on any
// write from the other tab.
const row = {
id: 's1',
name: 's1',
createdAt: 1,
previewTabs: [{ id: 't1', url: '/u', loc: '/u2' }]
} as Session
adoptRemoteRow(held, row)
expect(held.previewTabs?.[0].loc).toBe('/u2')
expect(held.previewTabs?.[0].friendlyLabel).toBe('My script')
expect(held.previewTabs?.[0].friendlyPath).toBe('f/a/b')
})
it('keeps a draft still inside its debounce window', () => {
// The pending flush writes this same object, so adopting the row's older
// text would persist it over what the user is still typing.
vi.useFakeTimers()
const held = session({ id: 'draft-race', name: 'draft-race', transient: true })
sessionState.sessions.push(held)
try {
setSessionDraftPrompt('draft-race', 'half-typed')
adoptRemoteRow(held, { id: 'draft-race', name: 'draft-race', createdAt: 0 } as Session)
expect(held.draftPrompt).toBe('half-typed')
} finally {
vi.clearAllTimers()
vi.useRealTimers()
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'draft-race')
}
})
})
describe('findEmptyLandingSession — where an unresolvable session link lands', () => {
it('takes an untouched draft', () => {
const restore = withTwoFamilies('forkA')
const blank = session({
id: 'landing-blank',
name: 'session-910',
pending_workspace_id: 'forkA',
transient: true
})
sessionState.sessions.push(blank)
try {
expect(findEmptyLandingSession()?.id).toBe('landing-blank')
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'landing-blank')
restore()
}
})
it('passes over a persisted session, onto which chat seeding can graft a conversation', () => {
const restore = withTwoFamilies('forkA')
// ensureChatIdsSeeded assigns untagged legacy chats to `!transient` sessions
// and initRuntime loads them, without touching a field checked here.
const abandoned = session({
id: 'landing-abandoned',
name: 'session-910',
pending_workspace_id: 'forkA'
})
const others = sessionState.sessions
sessionState.sessions = [abandoned]
try {
expect(findEmptyLandingSession()).toBeUndefined()
} finally {
sessionState.sessions = others
restore()
}
})
it('passes over a session that has been sent, so recovery never reopens a conversation', () => {
const restore = withTwoFamilies('forkA')
const sent = session({ id: 'landing-sent', name: 'session-911', workspace_id: 'forkA' })
// Sole candidate, so `undefined` pins the exclusion: `not.toBe` would also
// pass on any unrelated session the shared module state happens to hold.
const others = sessionState.sessions
sessionState.sessions = [sent]
try {
expect(findEmptyLandingSession()).toBeUndefined()
} finally {
sessionState.sessions = others
restore()
}
})
})
describe('withOpenSessionTeardown — the gate that holds recovery off during a delete', () => {
it('stays shut until the outermost teardown finishes', async () => {
let innerDone = false
await withOpenSessionTeardown(async () => {
await withOpenSessionTeardown(async () => {})
innerDone = true
expect(isTearingDownOpenSession()).toBe(true)
})
expect(innerDone).toBe(true)
expect(isTearingDownOpenSession()).toBe(false)
})
it('reopens when the teardown throws, so a failed delete cannot wedge recovery shut', async () => {
await expect(
withOpenSessionTeardown(async () => {
throw new Error('fork deletion failed')
})
).rejects.toThrow('fork deletion failed')
expect(isTearingDownOpenSession()).toBe(false)
})
})