feat(M2): FR-E6 链接气泡+图片入库——LinkBubbleMenu(编辑/移除href);ImageView 节点视图(相对路径转 vaultasset:// 协议展示);assetPickImage/assetWrite IPC + .attachments 入库;剪贴板粘贴图片入库;工具栏 链接/图片/网图 按钮;CSP 放行 vaultasset:;editor-link-image E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 13:32:13 +08:00
parent 4beebbc8d0
commit 2f4bcfd1e4
11 changed files with 295 additions and 5 deletions
@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
const PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
async function newDoc(a: { page: any; invoke: any }, title: string) {
await a.page.getByRole('button', { name: /新建文档/ }).click();
await a.page.getByPlaceholder(/输入文档标题/).fill(title);
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 });
}
test('FR-E6:链接气泡编辑(添加+移除)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e6-link-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '链接测试');
const editor = a.page.locator('.ProseMirror');
await editor.click();
await a.page.keyboard.type('点这里');
await a.page.keyboard.press('Control+A');
await a.page.locator('button[title="链接"]').click();
const dlg = a.page.getByPlaceholder(/example\.com/);
await dlg.fill('https://example.com');
await dlg.press('Enter');
let html: string = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? '');
expect(html).toContain('href="https://example.com"');
await a.page.locator('.ProseMirror a').click();
await expect(a.page.locator('.link-bubble')).toBeVisible({ timeout: 5000 });
await expect(a.page.locator('.link-bubble input')).toHaveValue('https://example.com');
await a.page.locator('.link-bubble').getByText('移除').click();
html = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? '');
expect(html).not.toContain('<a');
expect(html).not.toContain('href');
await a.app.close();
});
test('FR-E6:网络图片(手输网址)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e6-url-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '网图测试');
await a.page.locator('.ProseMirror').click();
await a.page.locator('button[title="网络图片(手输网址)"]').click();
const dlg = a.page.getByPlaceholder(/粘贴图片地址/);
await dlg.fill('https://cdn.example.com/x.png');
await dlg.press('Enter');
await expect(a.page.locator('.ProseMirror img')).toHaveCount(1, { timeout: 10000 });
const src = await a.page.locator('.ProseMirror img').getAttribute('src');
expect(src).toBe('https://cdn.example.com/x.png');
await a.app.close();
});
test('FR-E6:本地图片入库+展示(vaultasset 协议)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e6-local-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
const rel: string = await a.invoke('asset:write', { name: 't.png', dataBase64: PNG_B64 });
expect(rel.startsWith('.attachments/')).toBeTruthy();
// 磁盘文件确实写入
const disk = await fs.readFile(nodePath.join(vault, rel));
expect(disk.length).toBeGreaterThan(0);
await newDoc(a, 'doca');
await newDoc(a, 'docb');
await a.invoke('doc:write', { path: 'doca.md', meta: { title: 'doca' }, body: '![](' + rel + ')' });
await a.page.getByTestId('filetree').getByText('doca', { exact: true }).click();
await expect(a.page.locator('.ProseMirror img')).toHaveCount(1, { timeout: 10000 });
const src = await a.page.locator('.ProseMirror img').getAttribute('src');
expect(src?.startsWith('vaultasset:')).toBeTruthy();
await expect.poll(
() => a.page.locator('.ProseMirror img').evaluate((el: HTMLImageElement) => el.naturalWidth),
{ timeout: 8000 },
).toBeGreaterThan(0);
await a.app.close();
});