diff --git a/package.json b/package.json index 2385012a..d45346d1 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^4.0.18", "esbuild": "^0.27.3", @@ -86,6 +87,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "ws": "^8.19.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b12cfe9f..16fd9bcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.1.1 version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -210,6 +213,9 @@ importers: vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2) + ws: + specifier: ^8.19.0 + version: 8.19.0 mcp-server: dependencies: @@ -2043,6 +2049,9 @@ packages: '@types/use-sync-external-store@1.5.0': resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.55.0': resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6011,6 +6020,10 @@ snapshots: '@types/use-sync-external-store@1.5.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.13 + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/tests/smoke/ai-notes-visibility-fix.spec.ts b/tests/smoke/ai-notes-visibility-fix.spec.ts new file mode 100644 index 00000000..5ef8956b --- /dev/null +++ b/tests/smoke/ai-notes-visibility-fix.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from '@playwright/test' +import { WebSocketServer } from 'ws' + +const NEW_NOTE_PATH = '/Users/luca/Laputa/note/ai-created-note.md' +const NEW_NOTE_TITLE = 'AI Created Note' +const NEW_NOTE_CONTENT = `--- +title: ${NEW_NOTE_TITLE} +type: Note +--- + +# ${NEW_NOTE_TITLE} + +This note was created by the AI agent. +` + +function broadcast(wss: WebSocketServer, data: Record) { + const msg = JSON.stringify(data) + for (const client of wss.clients) { + if (client.readyState === 1) client.send(msg) + } +} + +/** Wait until at least one WebSocket client connects. */ +function waitForClient(wss: WebSocketServer, timeoutMs = 10000): Promise { + if (wss.clients.size > 0) return Promise.resolve() + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('No WS client connected')), timeoutMs) + wss.on('connection', () => { clearTimeout(timer); resolve() }) + }) +} + +test.describe('AI-created note visibility', () => { + let wss: WebSocketServer + /** When true, the intercepted list_vault response includes the new note. */ + let injectNote = false + + test.beforeEach(async () => { + wss = new WebSocketServer({ port: 9711 }) + }) + + test.afterEach(async () => { + wss.close() + await new Promise(r => setTimeout(r, 200)) + }) + + test('vault_changed + open_tab from MCP makes note visible and opens tab', async ({ page }) => { + // Intercept list_vault API calls. On reload (after vault_changed), + // the injected note will be included in the response. + await page.route('**/api/vault/list*', async (route) => { + const response = await route.fetch() + if (!injectNote) { + await route.fulfill({ response }) + return + } + const entries = await response.json() + const now = Date.now() + entries.push({ + path: NEW_NOTE_PATH, filename: 'ai-created-note.md', + title: NEW_NOTE_TITLE, isA: null, + aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, + cadence: null, archived: false, trashed: false, trashedAt: null, + modifiedAt: now, createdAt: now, fileSize: NEW_NOTE_CONTENT.length, + snippet: 'This note was created by the AI agent.', wordCount: 10, + relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + visible: null, outgoingLinks: [], properties: {}, + }) + await route.fulfill({ json: entries }) + }) + + // Intercept content fetch for the new note + await page.route('**/api/vault/content*ai-created-note*', async (route) => { + await route.fulfill({ json: { content: NEW_NOTE_CONTENT } }) + }) + + // Intercept all-content to include new note + await page.route('**/api/vault/all-content*', async (route) => { + const response = await route.fetch() + if (!injectNote) { + await route.fulfill({ response }) + return + } + const data = await response.json() + data[NEW_NOTE_PATH] = NEW_NOTE_CONTENT + await route.fulfill({ json: data }) + }) + + await page.goto('/') + + // Wait for note list to render + const noteList = page.locator('.app__note-list') + await expect(noteList).toBeVisible() + await expect(noteList.getByText(NEW_NOTE_TITLE)).not.toBeVisible() + + // Enable injection for subsequent reloads + injectNote = true + + // Wait for the frontend's useAiActivity WS to connect to our bridge + await waitForClient(wss) + + // Simulate MCP broadcasting vault_changed then open_tab + // (This is what happens when Claude Code calls open_note via MCP) + broadcast(wss, { type: 'ui_action', action: 'vault_changed', path: 'note/ai-created-note.md' }) + + // Verify: note appears in note list after vault reload + await expect(noteList.getByText(NEW_NOTE_TITLE)).toBeVisible({ timeout: 8000 }) + + // Send open_tab after vault is reloaded so the entry is in entriesByPath + broadcast(wss, { type: 'ui_action', action: 'open_tab', path: NEW_NOTE_PATH }) + + // Verify: note content is displayed in the editor (proves tab opened) + await expect(page.getByText('This note was created by the AI agent.')).toBeVisible({ timeout: 8000 }) + }) +})