Skip to content

Commit fee513a

Browse files
committed
feat(buddy): 支持文件、浏览器与产物选区引用
1 parent 102079e commit fee513a

83 files changed

Lines changed: 3397 additions & 73 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.playwright/scripts/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,20 @@ For concurrent runners, build once with `pnpm --filter @uselexora/lexora-buddy e
1919
Successful tests remove only their own instance data. Failed tests retain it for inspection; set `LEXORA_TEST_KEEP_DATA=1` to retain successful test data too. Each invocation writes a separate Playwright report under `.playwright/runs/<runId>`, with instance diagnostics, screenshots and traces attached.
2020

2121
Electron tests import `test` and `expect` from `fixtures/electron.mjs`. Scenarios belong in `__tests__/*.e2e.mjs`; helper unit tests use Vitest in `__tests__/*.spec.mjs`.
22+
23+
## 文件 / Markdown 选区引用
24+
25+
场景文件:`__tests__/selectionReferences.e2e.mjs`。使用上述隔离测试实例,验证真实文字选区、组件右键菜单、未分屏的“引用到对话”、原正文与焦点保留、来源编辑器撤销,以及真实双分屏下向指定另一草稿添加引用。
26+
27+
构建一次后可连续验证:
28+
29+
```sh
30+
pnpm --filter @uselexora/lexora-buddy exec electron-vite build
31+
pnpm exec playwright test --config .playwright/scripts/playwright.config.mjs selectionReferences.e2e.mjs --repeat-each=2
32+
```
33+
34+
创建测试空间仅是准备步骤:等待输入框就绪后分派 DOM 点击事件打开弹窗,避免启动期焦点或悬停状态造成不稳定;这一准备步骤不代表键盘创建空间已验收。实际引用操作仍使用真实键盘选区、鼠标右键和组件菜单点击。
35+
36+
主菜单和子菜单的长文字采用 DOM 文本夹具验证宽度上限与省略,不修改真实任务名称或身份。复制命令在测试实例主进程中拦截,仅验证可信接口分派,不读取或改变用户的系统剪贴板;它不代表剪贴板实际内容已经验收。引用菜单使用真实 NDropdown(下拉菜单组件)点击,不再以 Electron `Menu.popup`(弹出菜单接口)的点击测试桩代替。
37+
38+
开发预览仍使用隔离的 `development` 配置,安装版不在测试操作范围内。该场景不调用真实模型,也不替代人工桌面验收;完整范围见 `docs/specs/feature-013-workbench-selection-reference.md`。
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { Buffer } from 'node:buffer'
2+
import fs from 'node:fs/promises'
3+
import { createServer } from 'node:http'
4+
import path from 'node:path'
5+
import { DatabaseSync } from 'node:sqlite'
6+
import { expect, test } from '../fixtures/electron.mjs'
7+
8+
test('built-in artifact previews quote Markdown and source text to one selected draft and send frozen content', async ({ buddy }) => {
9+
const instance = await buddy.createInstance('artifact-selection')
10+
const directory = path.join(instance.home, 'documents')
11+
await fs.mkdir(directory)
12+
await fs.writeFile(path.join(directory, 'report.md'), '# Artifact report\n\nFrozen artifact paragraph.\n')
13+
await fs.writeFile(path.join(directory, 'data.csv'), 'item,budget,actual\nsoftware,500,800\n')
14+
await fs.writeFile(path.join(directory, 'code.ts'), 'export const answer = 42\n')
15+
await fs.writeFile(path.join(directory, 'image.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aDv8AAAAASUVORK5CYII=', 'base64'))
16+
await fs.writeFile(path.join(directory, 'unsupported.pdf'), '%PDF-1.4\nFixture placeholder\n')
17+
const requests = []
18+
const server = createServer(async (request, response) => {
19+
const chunks = []
20+
for await (const chunk of request)
21+
chunks.push(chunk)
22+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
23+
requests.push(body)
24+
const common = { id: `artifact-${requests.length}`, model: 'artifact-fixture', object: 'chat.completion.chunk', created: 1 }
25+
const publishing = requests.length === 1
26+
const delta = publishing
27+
? { tool_calls: [{ index: 0, id: 'present-fixtures', type: 'function', function: { name: 'lexora_output_present', arguments: JSON.stringify({ paths: ['report.md', 'data.csv', 'code.ts', 'image.png', 'unsupported.pdf'] }) } }] }
28+
: { content: 'Fixture reply.' }
29+
response.writeHead(200, { 'content-type': 'text/event-stream' })
30+
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', ...delta }, finish_reason: null }] })}\n\n`)
31+
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: publishing ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 32, completion_tokens: 8, total_tokens: 40 } })}\n\n`)
32+
response.end('data: [DONE]\n\n')
33+
})
34+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
35+
try {
36+
const { app, page, diagnostics } = await instance.launch()
37+
// Synthetic credentials only, inside the isolated Test profile. No user keys or remote model calls.
38+
await app.evaluate(({ app, safeStorage }) => {
39+
if (app.getName() !== 'Lexora Buddy Test')
40+
throw new Error('Synthetic credentials require the Test profile')
41+
Object.defineProperties(safeStorage, {
42+
isEncryptionAvailable: { configurable: true, value: () => true },
43+
getSelectedStorageBackend: { configurable: true, value: () => 'offline-fixture' },
44+
encryptString: { configurable: true, value: value => Buffer.from(`offline-fixture:${value}`) },
45+
decryptString: { configurable: true, value: (value) => {
46+
const text = value.toString('utf8')
47+
if (!text.startsWith('offline-fixture:'))
48+
throw new Error('Unexpected test credential')
49+
return text.slice('offline-fixture:'.length)
50+
} },
51+
})
52+
})
53+
await page.evaluate(() => window.lexoraDesktop.localChat.runtime.restart())
54+
await expect.poll(async () => (await page.evaluate(() => window.lexoraDesktop.localChat.runtime.getStatus())).status).toBe('ready')
55+
await page.evaluate(async (baseUrl) => {
56+
await window.lexoraDesktop.settings.update({ desktop: { contextPanelMode: 'task' } })
57+
const providers = window.lexoraDesktop.localChat.providers
58+
await providers.upsertCustom({ id: 'artifact-fixture', displayName: 'Artifact fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'artifact-fixture', name: 'Artifact fixture', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] })
59+
const stop = providers.onAuthChallenge((challenge) => {
60+
if (challenge.providerId === 'artifact-fixture' && challenge.type === 'secret')
61+
void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key')
62+
})
63+
try {
64+
await providers.login('artifact-fixture', 'api_key')
65+
}
66+
finally {
67+
stop()
68+
}
69+
await providers.setDefaultModel({ providerId: 'artifact-fixture', modelId: 'artifact-fixture', reasoning: null })
70+
}, `http://127.0.0.1:${server.address().port}/v1`)
71+
await app.evaluate(({ dialog, BrowserWindow }, directory) => {
72+
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] })
73+
globalThis.artifactSelectionCopies = 0
74+
for (const window of BrowserWindow.getAllWindows())
75+
window.webContents.copy = () => globalThis.artifactSelectionCopies += 1
76+
}, directory)
77+
await page.reload()
78+
const section = page.locator('.desktop-task-sidebar__spaces')
79+
await expect(page.locator('.desktop-workbench-area__tasks .tiptap:visible')).toBeVisible()
80+
await section.locator('.desktop-task-sidebar__section-add').dispatchEvent('click')
81+
const dialog = page.locator('.desktop-space-dialog')
82+
await dialog.getByPlaceholder('输入空间名称').fill('产出引用验收')
83+
await dialog.getByRole('button', { name: '选择目录', exact: true }).click()
84+
await dialog.getByRole('button', { name: '确定', exact: true }).click()
85+
await expect(dialog).toBeHidden()
86+
const inputs = page.locator('.desktop-workbench-area__tasks .tiptap:visible')
87+
const firstInput = inputs.first()
88+
await firstInput.fill('Publish the prepared fixture outputs.')
89+
await page.getByRole('button', { name: '发送消息', exact: true }).click()
90+
await expect.poll(() => completedRuns(instance.home)).toBe(1)
91+
const outputs = page.locator('.buddy-artifact-collection__item')
92+
await expect(outputs).toHaveCount(5)
93+
await firstInput.fill('Keep my latest artifact question.')
94+
const surface = page.locator('.desktop-artifact-context-surface')
95+
const menu = page.locator('.resource-selection-menu.n-dropdown:visible').first()
96+
const cards = page.locator('.desktop-chat-composer-wrap .resource-quote-card')
97+
async function open(name) {
98+
await outputs.filter({ has: page.locator(`.buddy-artifact-collection__name:text-is("${name}")`) }).click()
99+
await expect(surface).toBeVisible()
100+
}
101+
async function selectParagraph() {
102+
const paragraph = surface.locator('.desktop-document-content__markdown p').filter({ hasText: 'Frozen artifact paragraph.' })
103+
await expect(paragraph).toBeVisible()
104+
await paragraph.evaluate((element) => {
105+
const range = document.createRange()
106+
range.selectNodeContents(element)
107+
window.getSelection().removeAllRanges()
108+
window.getSelection().addRange(range)
109+
})
110+
await paragraph.click({ button: 'right' })
111+
await expect(menu).toBeVisible()
112+
}
113+
async function selectSource(text) {
114+
const editor = surface.locator('.desktop-monaco-file')
115+
await expect(editor.locator('.view-lines')).toContainText(text)
116+
const content = editor.locator('.monaco-scrollable-element').first()
117+
await content.click({ position: { x: 100, y: 12 } })
118+
await page.keyboard.press('Control+Home')
119+
await page.keyboard.press('Shift+End')
120+
await content.click({ button: 'right', position: { x: 100, y: 12 } })
121+
await expect(menu).toBeVisible()
122+
}
123+
await open('report.md')
124+
await selectParagraph()
125+
await menu.getByRole('menuitem', { name: '引用到对话', exact: true }).click()
126+
await expect(cards).toHaveCount(1)
127+
await expect(cards.first()).toContainText('本轮产出 · report.md')
128+
await expect(firstInput).toHaveText('Keep my latest artifact question.')
129+
expect(completedRuns(instance.home)).toBe(1)
130+
await selectParagraph()
131+
await menu.getByRole('menuitem', { name: '引用到对话', exact: true }).click()
132+
await expect(cards).toHaveCount(1)
133+
await expect(page.locator('.n-message')).toHaveCount(0)
134+
await cards.first().locator('.resource-quote-card__preview').click()
135+
await expect(page.locator('.resource-quote-preview:visible pre')).toHaveText('Frozen artifact paragraph.')
136+
await page.getByRole('button', { name: '定位来源', exact: true }).click()
137+
await expect(surface).toBeVisible()
138+
await page.keyboard.press('Escape')
139+
await surface.getByTestId('document-mode-source').click()
140+
await selectSource('# Artifact report')
141+
await menu.getByRole('menuitem', { name: '引用到对话', exact: true }).click()
142+
await expect(cards).toHaveCount(2)
143+
await expect(cards.last()).toContainText('L1–1')
144+
await open('data.csv')
145+
await selectSource('item,budget,actual')
146+
await menu.getByRole('menuitem', { name: '引用到对话', exact: true }).click()
147+
await expect(cards).toHaveCount(3)
148+
await open('unsupported.pdf')
149+
await expect(surface).toContainText('暂不支持在此预览该文件')
150+
await expect(surface.locator('.desktop-document-content')).toHaveCount(0)
151+
await open('image.png')
152+
await expect(surface.locator('img')).toBeVisible()
153+
await expect(surface.locator('.desktop-document-content')).toHaveCount(0)
154+
await open('code.ts')
155+
await selectSource('export const answer = 42')
156+
await expect(menu.getByRole('menuitem', { name: '复制', exact: true })).toHaveCount(1)
157+
await menu.getByRole('menuitem', { name: '复制', exact: true }).click()
158+
await expect.poll(() => app.evaluate(() => globalThis.artifactSelectionCopies)).toBe(1)
159+
await expect(cards).toHaveCount(3)
160+
await firstInput.click()
161+
await page.keyboard.press('Control+\\')
162+
await expect(inputs).toHaveCount(2)
163+
await inputs.nth(1).fill('Explain the frozen artifact code.')
164+
await firstInput.click()
165+
await selectSource('export const answer = 42')
166+
await menu.getByRole('menuitem', { name: '引用到其他对话', exact: true }).hover()
167+
const child = page.locator('.resource-selection-menu.n-dropdown:visible').filter({ has: page.locator('.resource-selection-menu-option__label').filter({ hasText: /^分屏 2/ }) }).last()
168+
await child.getByRole('menuitem', { name: /^分屏 2/ }).click()
169+
await expect(cards).toHaveCount(4)
170+
await expect(inputs.nth(1)).toHaveText('Explain the frozen artifact code.')
171+
await expect(firstInput).toHaveText('Keep my latest artifact question.')
172+
await page.screenshot({ path: path.join(instance.artifactDirectory, 'artifact-reference-split.png'), animations: 'disabled' })
173+
await fs.writeFile(path.join(directory, 'code.ts'), 'export const answer = 999\n')
174+
await inputs.nth(1).click()
175+
await page.locator('.desktop-workbench-area__tasks .desktop-task-editor').last().getByRole('button', { name: '发送消息', exact: true }).click()
176+
await expect.poll(() => completedRuns(instance.home)).toBe(2)
177+
const sent = JSON.stringify(requests.at(-1).messages)
178+
expect(sent).toContain('export const answer = 42')
179+
expect(sent).not.toContain('export const answer = 999')
180+
const userMessage = requests.at(-1).messages.findLast(message => message.role === 'user')
181+
const userText = typeof userMessage.content === 'string' ? userMessage.content : userMessage.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
182+
expect(userText).toContain('"title":"code.ts"')
183+
expect(userText).toContain('"format":"source"')
184+
for (const field of ['kind', 'artifactId', 'conversationId', 'runId', 'updatedAt', 'spaceId', 'directoryId', 'revision', 'id', 'textOffset'])
185+
expect(userText).not.toContain(`"${field}":`)
186+
expect(sent).toContain('not access grants')
187+
await expect(cards).toHaveCount(3)
188+
await page.screenshot({ path: path.join(instance.artifactDirectory, 'artifact-reference-after-send.png'), animations: 'disabled' })
189+
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
190+
}
191+
finally {
192+
await instance.stop()
193+
server.closeAllConnections()
194+
await new Promise(resolve => server.close(resolve))
195+
}
196+
})
197+
198+
function completedRuns(home) {
199+
const database = new DatabaseSync(path.join(home, 'buddy/buddy.sqlite3'), { readOnly: true })
200+
try {
201+
return database.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = ?').get('completed').count
202+
}
203+
finally {
204+
database.close()
205+
}
206+
}

0 commit comments

Comments
 (0)