test: add Playwright smoke test for AI note visibility and tab opening

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-07 03:56:37 +01:00
parent a0eb1a292d
commit 41cc824180
3 changed files with 130 additions and 1 deletions

View File

@@ -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"
}
}

13
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<string, unknown>) {
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<void> {
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 })
})
})