Skip to content

Commit 7109aad

Browse files
test(chat): verify persistence across relaunch
1 parent 530e229 commit 7109aad

3 files changed

Lines changed: 141 additions & 60 deletions

File tree

docs/FUNCTIONAL_TEST_STRATEGY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ really a test of a third-party framework or engine we merely depend on:
4646
dependency (binary/model) is absent, so it runs on a dev Mac + release builds and SKIPS in a
4747
plain `npm ci` CI rather than failing. This is where "the feature works" is proven.
4848
- **swift unit (XCTest / swift-testing via SwiftPM)** - pure logic inside a Swift package.
49-
- **e2e (Playwright Electron)** - the rendered app tour (e2e/, 24 tests today).
49+
- **e2e (Playwright Electron)** - the rendered app tour (e2e/, 25 tests today).
5050
- **gateway smoke (`npm run smoke`)** - the OpenAI `/v1` surface against a running app.
5151

5252
## Surface matrix (status - keep current)
@@ -65,7 +65,7 @@ really a test of a third-party framework or engine we merely depend on:
6565
| Capture -> OCR -> memory ingest seam | integration | vitest: feed a fixture frame through the ingest path into a temp DB | TODO |
6666
| RAG end-to-end (retrieve -> prompt -> answer) | integration | vitest: seed a temp SQLite, run retrieval, assert grounded context | TODO |
6767
| Vault (kdbx + Argon2) | integration | vitest vs temp dir | DONE (`vault-service.test.ts`) |
68-
| Renderer surfaces render | e2e | Playwright tour | DONE (24) |
68+
| Renderer surfaces render | e2e | Playwright tour | DONE (25) |
6969
| Pro dictation / clipboard / replay | e2e + unit | Playwright (`pro.spec.ts`) + pro unit | PARTIAL |
7070

7171
## Rules

docs/P0_P2_INTEGRATION_COVERAGE.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ manual claim does not count as complete integration coverage.
88
## Current status - 2026-07-17
99

1010
- Scope: 155 journeys - 74 P0, 71 P1, and 10 P2.
11-
- Covered by direct integration or E2E evidence: 27 - 20 P0, 6 P1, and 1 P2.
12-
- Left: 128 - including partially covered journeys whose exact release behavior is not yet proven.
11+
- Covered by direct integration or E2E evidence: 28 - 21 P0, 6 P1, and 1 P2.
12+
- Left: 127 - including partially covered journeys whose exact release behavior is not yet proven.
1313
- Green gates today:
1414
- `npm test`: 202 files passed, 1 skipped; 2,190 tests passed, 1 skipped.
1515
- `npm run test:coverage`: 96.75% statements, 91.54% branches, 96.14% functions,
1616
97.52% lines.
1717
- `npm run test:db`: 13 files and 105 real SQLite integration tests passed; Electron ABI
1818
restored afterward.
19-
- `npm run test:e2e`: 24 Playwright Electron tests passed against fresh synthetic temp profiles.
19+
- `npm run test:e2e`: 25 Playwright Electron tests passed against fresh synthetic temp profiles.
2020
- Both TypeScript projects pass.
21-
- Not yet a clean handoff: the exact P0-P2 journey count is 27/155, and strict ESLint currently
21+
- Not yet a clean handoff: the exact P0-P2 journey count is 28/155, and strict ESLint currently
2222
exposes a legacy backlog. Neither is hidden by the coverage percentage.
2323

2424
## Covered P0 journeys
@@ -27,6 +27,9 @@ manual claim does not count as complete integration coverage.
2727
new temp `OFFGRID_USER_DATA` directory and verifies first-run onboarding and the preload bridge.
2828
- #9 - Fresh onboarding completes. `e2e/smoke.spec.ts` drives the real onboarding flow and lands on
2929
Models.
30+
- #43 - Chat survives relaunch. `e2e/chat-memory.spec.ts` creates multiple scoped and unscoped
31+
conversations with messages and context through production IPC, fully closes Electron, reopens
32+
the same profile, and verifies every association and payload through the reloaded preload path.
3033
- #51 - Create a project. `src/main/__tests__/rag-store-integration.dbtest.ts` exercises the real
3134
project store against temp SQLite and verifies the round trip.
3235
- #56 - Delete project cascades. `src/main/__tests__/project-delete-cascade.dbtest.ts` uses the real
@@ -142,7 +145,6 @@ manual claim does not count as complete integration coverage.
142145
- #40 - Queued message order.
143146
- #41 - Conversation switch isolation.
144147
- #42 - Project switch isolation.
145-
- #43 - Chat survives relaunch.
146148
- #44 - Rename conversation.
147149
- #46 - Copy assistant reply.
148150
- #47 - Regenerate reply.

e2e/chat-memory.spec.ts

Lines changed: 132 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
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
2122
let page: Page
2223
let 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: /Continue|Start using Off Grid/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: /chat|mind|ask/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: /set up your local ai/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

5687
test('navigates to the chat screen', async () => {
57-
// Find the chat/mind-share nav item and click it.
58-
const chatNav = page.getByRole('button', { name: /chat|mind|ask/i }).first()
59-
if (await chatNav.isVisible().catch(() => false)) {
60-
await chatNav.click()
61-
await page.waitForTimeout(500)
62-
}
88+
await expect(page.getByPlaceholder(/ask anything/i)).toBeVisible()
6389
await page.screenshot({ path: 'e2e/screenshots/chat-screen.png', fullPage: false })
6490
})
6591

6692
test('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: /set up your local ai/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: /memory|no memory/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: /no memory|memory/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: /all memory/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

150147
test('chat composer renders and accepts input', async () => {
151148
const composer = page.getByPlaceholder(/ask anything/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(/ask anything/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

Comments
 (0)