22 * Chat + memory regression tests for:
33 * - No-memory toggle actually sticking (fix: assignProject no longer overrides noMemory)
44 * - Streaming placeholder appearing immediately (fix: streamConvRef routes tokens to correct conv)
5+ * - Conversation, message, scope, and project persistence across a full process relaunch
56 *
67 * Runs against the built app with OFFGRID_PRO=1 so the memory dropdown is visible.
78 * No LLM model is expected — we only assert UI state and IPC plumbing, not model output.
@@ -21,8 +22,7 @@ let app: ElectronApplication
2122let page : Page
2223let userDataDir : string
2324
24- test . beforeAll ( async ( ) => {
25- userDataDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'offgrid-chat-e2e-' ) )
25+ const launchApp = async ( ) : Promise < void > => {
2626 app = await electron . launch ( {
2727 args : [ '.' ] ,
2828 env : {
@@ -34,18 +34,49 @@ test.beforeAll(async () => {
3434 } )
3535 page = await app . firstWindow ( )
3636 await page . waitForLoadState ( 'domcontentloaded' )
37+ }
38+
39+ const closeApp = async ( ) : Promise < void > => {
40+ const child = app . process ( )
41+ await app . close ( )
42+ if ( child . exitCode !== null ) return
43+ await new Promise < void > ( ( resolve ) => {
44+ child . once ( 'exit' , ( ) => resolve ( ) )
45+ } )
46+ }
3747
38- // Skip onboarding so we land in the app shell.
48+ const enterChat = async ( ) : Promise < void > => {
3949 for ( let i = 0 ; i < 8 ; i ++ ) {
4050 const btn = page . getByRole ( 'button' , { name : / C o n t i n u e | S t a r t u s i n g O f f G r i d / i } )
4151 if ( ! ( await btn . isVisible ( ) . catch ( ( ) => false ) ) ) break
4252 await btn . click ( )
4353 await page . waitForTimeout ( 300 )
4454 }
55+
56+ const chatNav = page . getByRole ( 'button' , { name : / c h a t | m i n d | a s k / i } ) . first ( )
57+ await expect ( chatNav ) . toBeVisible ( )
58+ await chatNav . click ( )
59+ await page . waitForTimeout ( 500 )
60+
61+ const banner = page
62+ . locator ( 'div' )
63+ . filter ( { hasText : / s e t u p y o u r l o c a l a i / i } )
64+ . first ( )
65+ if ( await banner . isVisible ( ) . catch ( ( ) => false ) ) {
66+ await banner . locator ( 'button' ) . last ( ) . click ( )
67+ await page . waitForTimeout ( 300 )
68+ }
69+ await page . keyboard . press ( 'Escape' )
70+ }
71+
72+ test . beforeEach ( async ( ) => {
73+ userDataDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'offgrid-chat-e2e-' ) )
74+ await launchApp ( )
75+ await enterChat ( )
4576} )
4677
47- test . afterAll ( async ( ) => {
48- await app ?. close ( )
78+ test . afterEach ( async ( ) => {
79+ if ( app ) await closeApp ( )
4980 try {
5081 fs . rmSync ( userDataDir , { recursive : true , force : true } )
5182 } catch {
@@ -54,44 +85,17 @@ test.afterAll(async () => {
5485} )
5586
5687test ( 'navigates to the chat screen' , async ( ) => {
57- // Find the chat/mind-share nav item and click it.
58- const chatNav = page . getByRole ( 'button' , { name : / c h a t | m i n d | a s k / i } ) . first ( )
59- if ( await chatNav . isVisible ( ) . catch ( ( ) => false ) ) {
60- await chatNav . click ( )
61- await page . waitForTimeout ( 500 )
62- }
88+ await expect ( page . getByPlaceholder ( / a s k a n y t h i n g / i) ) . toBeVisible ( )
6389 await page . screenshot ( { path : 'e2e/screenshots/chat-screen.png' , fullPage : false } )
6490} )
6591
6692test ( 'memory toggle: No memory sticks after selection' , async ( ) => {
67- // Dismiss the "Set up your local AI" banner (and any other overlays) that block clicks.
68- const dismissBtns = page
69- . locator ( 'button' )
70- . filter ( { hasText : '' } )
71- . filter ( { has : page . locator ( 'svg' ) } )
72- const banner = page
73- . locator ( 'div' )
74- . filter ( { hasText : / s e t u p y o u r l o c a l a i / i } )
75- . first ( )
76- if ( await banner . isVisible ( ) . catch ( ( ) => false ) ) {
77- const closeBtn = banner . locator ( 'button' ) . last ( )
78- await closeBtn . click ( ) . catch ( ( ) => { } )
79- await page . waitForTimeout ( 300 )
80- }
81- void dismissBtns // suppress unused warning
82- await page . keyboard . press ( 'Escape' )
83- await page . waitForTimeout ( 200 )
84-
8593 // Open the memory/scope dropdown.
8694 const memoryBtn = page
8795 . locator ( 'button' )
8896 . filter ( { hasText : / m e m o r y | n o m e m o r y / i } )
8997 . first ( )
90- if ( ! ( await memoryBtn . isVisible ( ) . catch ( ( ) => false ) ) ) {
91- test . skip ( true , 'Memory toggle not visible — may be on a different screen' )
92- return
93- }
94-
98+ await expect ( memoryBtn ) . toBeVisible ( )
9599 await memoryBtn . click ( { force : true } )
96100 await page . waitForTimeout ( 500 )
97101 await page . screenshot ( { path : 'e2e/screenshots/memory-dropdown-open.png' } )
@@ -121,19 +125,12 @@ test('memory toggle: All memory sticks after selection', async () => {
121125 . locator ( 'button' )
122126 . filter ( { hasText : / n o m e m o r y | m e m o r y / i } )
123127 . first ( )
124- if ( ! ( await memoryBtn . isVisible ( ) . catch ( ( ) => false ) ) ) {
125- test . skip ( true , 'Memory toggle not visible' )
126- return
127- }
128-
128+ await expect ( memoryBtn ) . toBeVisible ( )
129129 await memoryBtn . click ( { force : true } )
130130 await page . waitForTimeout ( 200 )
131131
132132 const allMemoryItem = page . getByRole ( 'menuitem' , { name : / a l l m e m o r y / i } )
133- if ( ! ( await allMemoryItem . isVisible ( ) . catch ( ( ) => false ) ) ) {
134- test . skip ( true , 'All memory item not in dropdown (non-pro build?)' )
135- return
136- }
133+ await expect ( allMemoryItem ) . toBeVisible ( )
137134 await allMemoryItem . click ( )
138135 await page . waitForTimeout ( 300 )
139136
@@ -149,11 +146,7 @@ test('memory toggle: All memory sticks after selection', async () => {
149146
150147test ( 'chat composer renders and accepts input' , async ( ) => {
151148 const composer = page . getByPlaceholder ( / a s k a n y t h i n g / i)
152- if ( ! ( await composer . isVisible ( ) . catch ( ( ) => false ) ) ) {
153- test . skip ( true , 'Chat composer not visible' )
154- return
155- }
156-
149+ await expect ( composer ) . toBeVisible ( )
157150 await composer . fill ( 'Hello, test message' )
158151 await page . screenshot ( { path : 'e2e/screenshots/chat-composer-filled.png' } )
159152
@@ -174,11 +167,7 @@ test('streaming placeholder appears immediately after send', async () => {
174167 // It validates the fix — previously nothing appeared until the full response
175168 // resolved because stream tokens routed to the wrong conversation bucket.
176169 const composer = page . getByPlaceholder ( / a s k a n y t h i n g / i)
177- if ( ! ( await composer . isVisible ( ) . catch ( ( ) => false ) ) ) {
178- test . skip ( true , 'Chat composer not visible' )
179- return
180- }
181-
170+ await expect ( composer ) . toBeVisible ( )
182171 await composer . fill ( 'What did I work on today?' )
183172 await page . keyboard . press ( 'Enter' )
184173
@@ -216,3 +205,93 @@ test('streaming placeholder appears immediately after send', async () => {
216205
217206 await page . screenshot ( { path : 'e2e/screenshots/streaming-after-send.png' } )
218207} )
208+
209+ test ( 'conversations, messages, scopes, and project associations survive relaunch' , async ( ) => {
210+ const seeded = await page . evaluate ( async ( ) => {
211+ const projectId = await window . api . createProject ( { name : 'Relaunch Project' } )
212+ await window . api . createRagConversation ( 'persist-general' , 'Persistent General' , null )
213+ await window . api . addRagMessage ( 'persist-general' , 'user' , 'general question' )
214+ await window . api . addRagMessage ( 'persist-general' , 'assistant' , 'general answer' , {
215+ sources : [ 'local-memory' ]
216+ } )
217+ await window . api . createRagConversation ( 'persist-project' , 'Persistent Project' , projectId )
218+ await window . api . addRagMessage ( 'persist-project' , 'user' , 'project question' )
219+ await window . api . addRagMessage ( 'persist-project' , 'assistant' , 'project answer' , {
220+ projectId
221+ } )
222+ return { projectId }
223+ } )
224+
225+ await closeApp ( )
226+ await launchApp ( )
227+
228+ const persisted = await page . evaluate ( async ( { projectId } ) => {
229+ const conversations = await window . api . getRagConversations ( )
230+ const projects = await window . api . listProjects ( )
231+ const generalMessages = await window . api . getRagMessages ( 'persist-general' )
232+ const projectMessages = await window . api . getRagMessages ( 'persist-project' )
233+ const selectConversation = (
234+ id : string
235+ ) : {
236+ id : string
237+ title : string | null
238+ projectId : string | null | undefined
239+ messageCount : number | undefined
240+ } | null => {
241+ const conversation = conversations . find ( ( item ) => item . id === id )
242+ return conversation
243+ ? {
244+ id : conversation . id ,
245+ title : conversation . title ,
246+ projectId : conversation . project_id ,
247+ messageCount : conversation . message_count
248+ }
249+ : null
250+ }
251+ const selectMessages = (
252+ messages : typeof generalMessages
253+ ) : Array < { role : string ; content : string ; context : string | null } > =>
254+ messages . map ( ( message ) => ( {
255+ role : message . role ,
256+ content : message . content ,
257+ context : message . context
258+ } ) )
259+ return {
260+ projectExists : projects . some ( ( project ) => project . id === projectId ) ,
261+ general : selectConversation ( 'persist-general' ) ,
262+ project : selectConversation ( 'persist-project' ) ,
263+ generalMessages : selectMessages ( generalMessages ) ,
264+ projectMessages : selectMessages ( projectMessages )
265+ }
266+ } , seeded )
267+
268+ expect ( persisted . projectExists ) . toBe ( true )
269+ expect ( persisted . general ) . toEqual ( {
270+ id : 'persist-general' ,
271+ title : 'Persistent General' ,
272+ projectId : null ,
273+ messageCount : 2
274+ } )
275+ expect ( persisted . project ) . toEqual ( {
276+ id : 'persist-project' ,
277+ title : 'Persistent Project' ,
278+ projectId : seeded . projectId ,
279+ messageCount : 2
280+ } )
281+ expect ( persisted . generalMessages ) . toEqual ( [
282+ { role : 'user' , content : 'general question' , context : null } ,
283+ {
284+ role : 'assistant' ,
285+ content : 'general answer' ,
286+ context : JSON . stringify ( { sources : [ 'local-memory' ] } )
287+ }
288+ ] )
289+ expect ( persisted . projectMessages ) . toEqual ( [
290+ { role : 'user' , content : 'project question' , context : null } ,
291+ {
292+ role : 'assistant' ,
293+ content : 'project answer' ,
294+ context : JSON . stringify ( { projectId : seeded . projectId } )
295+ }
296+ ] )
297+ } )
0 commit comments