feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0)
This commit is contained in:
+250
@@ -0,0 +1,250 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
launchApp,
|
||||
stubOpenDialogSeq,
|
||||
stubSaveDialog,
|
||||
makeTmpDir,
|
||||
removeTmpDir,
|
||||
writeTextFixture,
|
||||
pasteText
|
||||
} from './helpers'
|
||||
|
||||
/**
|
||||
* DiffLens E2E 主链路验收(对 build 产物运行真实 Electron):
|
||||
* 覆盖 jsdom 单测无法触达的主进程解码 / 报告写盘 / 剪贴板 IPC / 偏好持久化生命周期。
|
||||
* 运行前必须先 npm run build。
|
||||
*/
|
||||
|
||||
test.describe('启动与基础交互', () => {
|
||||
test('应用启动:窗口标题与双空态面板', async () => {
|
||||
const { app, window } = await launchApp()
|
||||
await expect(window).toHaveTitle('DiffLens')
|
||||
await expect(window.getByText('选择左侧文件')).toBeVisible()
|
||||
await expect(window.getByText('选择右侧文件')).toBeVisible()
|
||||
// 空态不出现导出入口
|
||||
await expect(window.getByRole('button', { name: '导出报告' })).toHaveCount(0)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('粘贴两侧文本:差异渲染与统计徽章', async () => {
|
||||
const { app, window } = await launchApp()
|
||||
await pasteText(window, 'left', 'alpha\nbeta\ngamma')
|
||||
await expect(window.getByRole('button', { name: '导出报告' })).toBeVisible()
|
||||
await pasteText(window, 'right', 'alpha\nBETA\ngamma')
|
||||
// 差异徽章与状态栏结论
|
||||
await expect(window.getByText('有差异')).toBeVisible()
|
||||
await expect(window.getByText('~1')).toBeVisible()
|
||||
// 修改行内容渲染(词级高亮行;exact 避免大小写不敏感的重复命中)
|
||||
await expect(window.getByText('beta', { exact: true })).toBeVisible()
|
||||
await expect(window.getByText('BETA', { exact: true })).toBeVisible()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('文件打开与编码识别(主进程真实解码链路)', () => {
|
||||
let dir: string
|
||||
|
||||
test.beforeAll(async () => {
|
||||
dir = await makeTmpDir('difflens-e2e-enc-')
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await removeTmpDir(dir)
|
||||
})
|
||||
|
||||
test('打开 GBK 与 UTF-8 BOM 文件:编码标注与内容正确渲染', async () => {
|
||||
const gbkPath = await writeTextFixture(
|
||||
dir,
|
||||
'gbk.txt',
|
||||
'中文内容第一行\n第二行数据',
|
||||
'gbk'
|
||||
)
|
||||
const bomPath = await writeTextFixture(dir, 'bom.txt', 'BOM 首行\nBOM 第二行', 'utf8-bom')
|
||||
const { app, window } = await launchApp()
|
||||
await stubOpenDialogSeq(app, [[gbkPath], [bomPath]])
|
||||
await window.getByRole('button', { name: '打开左侧' }).click()
|
||||
await expect(window.getByText('中文内容第一行')).toBeVisible()
|
||||
await window.getByRole('button', { name: '打开右侧' }).click()
|
||||
await expect(window.getByText('BOM 首行')).toBeVisible()
|
||||
// 面板头编码标注(主进程探测结果;exact 避免文件名 gbk.txt 的子串误匹配)
|
||||
await expect(window.locator('.pane-meta').filter({ hasText: 'GBK' })).toBeVisible()
|
||||
await expect(window.locator('.pane-meta').filter({ hasText: 'UTF-8 (BOM)' })).toBeVisible()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('大体积 GBK 文件:后台 worker 解码后完整渲染', async () => {
|
||||
// 约 300KB(≥ 解码 worker 快路径阈值 256KB):走 worker_threads 后台解码链路
|
||||
const bigLines = Array.from({ length: 15000 }, (_, i) => `中文大文件行${i}数据`).join('\n')
|
||||
const bigPath = await writeTextFixture(dir, 'big-gbk.txt', bigLines, 'gbk')
|
||||
const { app, window } = await launchApp()
|
||||
await stubOpenDialogSeq(app, [[bigPath]])
|
||||
await window.getByRole('button', { name: '打开左侧' }).click()
|
||||
// 首行在视口内;末行在虚拟滚动视口外,以状态栏总行数断言完整性
|
||||
await expect(window.getByText('中文大文件行0数据')).toBeVisible()
|
||||
await expect(window.locator('.status-item').filter({ hasText: '15000 行' })).toBeVisible()
|
||||
await expect(window.locator('.pane-meta').filter({ hasText: 'GBK' })).toBeVisible()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('比较选项端到端', () => {
|
||||
test('忽略大小写与忽略所有空白组合生效', async () => {
|
||||
const { app, window } = await launchApp()
|
||||
await pasteText(window, 'left', 'alpha\nbeta')
|
||||
await pasteText(window, 'right', 'ALPHA\n beta')
|
||||
// 默认严格对比:两处差异
|
||||
await expect(window.getByText('有差异')).toBeVisible()
|
||||
// 仅忽略大小写:仍有行首空白差异
|
||||
await window.getByLabel('忽略大小写').check()
|
||||
await expect(window.getByText('有差异')).toBeVisible()
|
||||
// 再忽略所有空白:判为完全一致
|
||||
await window.getByLabel('忽略所有空白').check()
|
||||
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('字符级对比:跨行重组判为一致', async () => {
|
||||
const { app, window } = await launchApp()
|
||||
await pasteText(window, 'left', 'ab cd')
|
||||
await pasteText(window, 'right', 'ab\ncd')
|
||||
await expect(window.getByText('有差异')).toBeVisible()
|
||||
await window.getByLabel('字符级对比').check()
|
||||
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('差异导航与视图', () => {
|
||||
test('F7 / Shift+F7 导航与仅看差异折叠', async () => {
|
||||
const { app, window } = await launchApp()
|
||||
await pasteText(window, 'left', 'a\nX1\nb\nY1\nc')
|
||||
await pasteText(window, 'right', 'a\nX2\nb\nY2\nc')
|
||||
await expect(window.getByText('1 / 2')).toBeVisible()
|
||||
await window.keyboard.press('F7')
|
||||
await expect(window.getByText('2 / 2')).toBeVisible()
|
||||
await window.keyboard.press('Shift+F7')
|
||||
await expect(window.getByText('1 / 2')).toBeVisible()
|
||||
// 仅看差异:未更改行折叠为提示行
|
||||
await window.getByLabel('仅看差异').check()
|
||||
await expect(window.getByText(/已折叠/).first()).toBeVisible()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('交换左右侧后面板文件互换', async () => {
|
||||
const dir = await makeTmpDir('difflens-e2e-swap-')
|
||||
try {
|
||||
const aPath = await writeTextFixture(dir, 'a.txt', 'aaa')
|
||||
const bPath = await writeTextFixture(dir, 'b.txt', 'bbb')
|
||||
const { app, window } = await launchApp()
|
||||
await stubOpenDialogSeq(app, [[aPath], [bPath]])
|
||||
await window.getByRole('button', { name: '打开左侧' }).click()
|
||||
await expect(window.locator('.pane-file').filter({ hasText: 'a.txt' })).toBeVisible()
|
||||
await window.getByRole('button', { name: '打开右侧' }).click()
|
||||
await expect(window.locator('.pane-file').filter({ hasText: 'b.txt' })).toBeVisible()
|
||||
await window.getByRole('button', { name: '⇄ 交换左右' }).click()
|
||||
// 面板头文件名互换(用 .pane-file 定位,避免与状态栏文件名混淆)
|
||||
const paneFiles = window.locator('.pane-file')
|
||||
await expect(paneFiles.nth(0)).toHaveText(/b\.txt/)
|
||||
await expect(paneFiles.nth(1)).toHaveText(/a\.txt/)
|
||||
await app.close()
|
||||
} finally {
|
||||
await removeTmpDir(dir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('报告导出(主进程写盘链路)', () => {
|
||||
test('导出 HTML 报告:文件落盘且包含结论', async () => {
|
||||
const dir = await makeTmpDir('difflens-e2e-report-')
|
||||
try {
|
||||
const { app, window } = await launchApp()
|
||||
await pasteText(window, 'left', 'same\ndiff-left')
|
||||
await pasteText(window, 'right', 'same\ndiff-right')
|
||||
const savePath = join(dir, 'report.html')
|
||||
await stubSaveDialog(app, savePath)
|
||||
await window.getByRole('button', { name: '导出报告' }).click()
|
||||
await window.getByRole('button', { name: 'HTML 报告' }).click()
|
||||
await expect(window.getByText(/报告已保存/)).toBeVisible()
|
||||
const html = await readFile(savePath, 'utf-8')
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('共 1 处不同:修改 1 行')
|
||||
// 统计卡与未更改行渲染(diff-left 被词级高亮拆分 span,不作整串断言)
|
||||
expect(html).toContain('~1')
|
||||
expect(html).toContain('same')
|
||||
await app.close()
|
||||
} finally {
|
||||
await removeTmpDir(dir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('文件夹对比(主进程真实扫描链路)', () => {
|
||||
test('选择两个文件夹:状态判定、过滤与双击进入单文件对比', async () => {
|
||||
const root = await makeTmpDir('difflens-e2e-folder-')
|
||||
try {
|
||||
const leftDir = join(root, 'left')
|
||||
const rightDir = join(root, 'right')
|
||||
const { mkdir, writeFile: wf } = await import('fs/promises')
|
||||
await mkdir(join(leftDir, 'sub'), { recursive: true })
|
||||
await mkdir(join(rightDir, 'sub'), { recursive: true })
|
||||
await wf(join(leftDir, 'same.txt'), 'identical')
|
||||
await wf(join(rightDir, 'same.txt'), 'identical')
|
||||
await wf(join(leftDir, 'diff.txt'), 'left content\nsame tail')
|
||||
await wf(join(rightDir, 'diff.txt'), 'right content\nsame tail')
|
||||
await wf(join(leftDir, 'only-left.txt'), 'L')
|
||||
await wf(join(leftDir, 'sub', 'nested.txt'), 'nested L')
|
||||
await wf(join(rightDir, 'sub', 'nested.txt'), 'nested R')
|
||||
|
||||
const { app, window } = await launchApp()
|
||||
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
|
||||
await window.getByRole('button', { name: '对比文件夹' }).click()
|
||||
|
||||
// 文件夹模式:路径卡片与统计徽章
|
||||
await expect(window.getByText('相同 1')).toBeVisible()
|
||||
await expect(window.getByText('不同 2')).toBeVisible()
|
||||
await expect(window.getByText('仅左 1')).toBeVisible()
|
||||
// 默认仅看差异:same.txt 被过滤,差异条目可见
|
||||
await expect(window.getByText('diff.txt')).toBeVisible()
|
||||
await expect(window.getByText('sub/nested.txt')).toBeVisible()
|
||||
await expect(window.getByText('same.txt')).toHaveCount(0)
|
||||
|
||||
// 双击差异条目进入单文件对比
|
||||
await window.getByText('diff.txt').dblclick()
|
||||
await expect(window.getByText('left content')).toBeVisible()
|
||||
await expect(window.getByText('right content')).toBeVisible()
|
||||
await expect(window.getByText('有差异')).toBeVisible()
|
||||
await expect(window.getByRole('button', { name: '← 返回文件夹对比' })).toBeVisible()
|
||||
|
||||
// 返回文件夹列表(扫描结果保留)
|
||||
await window.getByRole('button', { name: '← 返回文件夹对比' }).click()
|
||||
await expect(window.getByText('相同 1')).toBeVisible()
|
||||
await expect(window.getByText('diff.txt')).toBeVisible()
|
||||
await app.close()
|
||||
} finally {
|
||||
await removeTmpDir(root)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('偏好持久化(跨实例重启)', () => {
|
||||
test('重启后恢复比较选项与视图开关', async () => {
|
||||
// 第一实例 fresh 清掉历史残留,建立干净基线后再写入偏好
|
||||
const first = await launchApp({ fresh: true })
|
||||
await pasteText(first.window, 'left', 'alpha')
|
||||
await first.window.getByLabel('忽略大小写').check()
|
||||
await first.window.getByLabel('仅看差异').check()
|
||||
await expect(first.window.getByLabel('忽略大小写')).toBeChecked()
|
||||
// 等 localStorage 写回完成再关闭
|
||||
await first.window.waitForTimeout(500)
|
||||
await first.app.close()
|
||||
|
||||
// 同一 userData 下重启(不清理):偏好应恢复
|
||||
const second = await launchApp({ fresh: false })
|
||||
await pasteText(second.window, 'left', 'alpha')
|
||||
await expect(second.window.getByLabel('忽略大小写')).toBeChecked()
|
||||
await expect(second.window.getByLabel('仅看差异')).toBeChecked()
|
||||
await second.app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
import { mkdtemp, writeFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import iconv from 'iconv-lite'
|
||||
|
||||
/**
|
||||
* E2E 辅助:启动构建产物(out/)对应的真实 Electron 应用,
|
||||
* 以及文件对话框 stub 与临时 fixture 生成。
|
||||
* Electron 模式复用项目自带 Electron 二进制,无需下载 Playwright 浏览器。
|
||||
*/
|
||||
|
||||
export interface LaunchedApp {
|
||||
app: ElectronApplication
|
||||
window: Page
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动应用并等待首个窗口就绪。
|
||||
* fresh(默认 true):清除上一轮测试残留的偏好持久化(localStorage 跨实例共享 userData)
|
||||
* 并重载页面,保证以默认选项启动;偏好持久化用例的验证实例显式传 false。
|
||||
*/
|
||||
export async function launchApp(options: { fresh?: boolean } = {}): Promise<LaunchedApp> {
|
||||
const { fresh = true } = options
|
||||
const app = await electron.launch({ args: ['.'] })
|
||||
const window = await app.firstWindow()
|
||||
await window.waitForLoadState('domcontentloaded')
|
||||
if (fresh) {
|
||||
await window.evaluate(() => window.localStorage.clear())
|
||||
await window.reload()
|
||||
await window.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
return { app, window }
|
||||
}
|
||||
|
||||
/**
|
||||
* 主进程 stub:showOpenDialog 按调用序返回指定路径(每次调用取该组第一个文件)。
|
||||
* 闭包计数状态保留在主进程,覆盖「打开左侧 → 打开右侧」的连续调用。
|
||||
*/
|
||||
export async function stubOpenDialogSeq(app: ElectronApplication, seqPaths: string[][]): Promise<void> {
|
||||
await app.evaluate(({ dialog }, seq: string[][]) => {
|
||||
let i = 0
|
||||
dialog.showOpenDialog = async () => {
|
||||
const paths = seq[Math.min(i, seq.length - 1)] ?? []
|
||||
i++
|
||||
return { canceled: false, filePaths: paths }
|
||||
}
|
||||
}, seqPaths)
|
||||
}
|
||||
|
||||
/** 主进程 stub:showSaveDialog 固定返回指定保存路径 */
|
||||
export async function stubSaveDialog(app: ElectronApplication, savePath: string): Promise<void> {
|
||||
await app.evaluate(({ dialog }, p: string) => {
|
||||
dialog.showSaveDialog = async () => ({ canceled: false, filePath: p })
|
||||
}, savePath)
|
||||
}
|
||||
|
||||
/** 创建临时目录(存放测试 fixture / 导出产物) */
|
||||
export async function makeTmpDir(prefix = 'difflens-e2e-'): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), prefix))
|
||||
}
|
||||
|
||||
/** 清理临时目录(递归、不存在时忽略) */
|
||||
export async function removeTmpDir(dir: string): Promise<void> {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文本 fixture:encoding 为 'utf8'(可带 bom)或 'gbk'(iconv-lite 编码)。
|
||||
* 返回文件绝对路径。
|
||||
*/
|
||||
export async function writeTextFixture(
|
||||
dir: string,
|
||||
name: string,
|
||||
content: string,
|
||||
encoding: 'utf8' | 'utf8-bom' | 'gbk' = 'utf8'
|
||||
): Promise<string> {
|
||||
const path = join(dir, name)
|
||||
if (encoding === 'gbk') {
|
||||
await writeFile(path, iconv.encode(content, 'gbk'))
|
||||
} else if (encoding === 'utf8-bom') {
|
||||
await writeFile(path, '\ufeff' + content, 'utf8')
|
||||
} else {
|
||||
await writeFile(path, content, 'utf8')
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/** 通过「粘贴文本」入口向指定侧粘贴内容(覆盖粘贴弹窗全链路) */
|
||||
export async function pasteText(window: Page, side: 'left' | 'right', text: string): Promise<void> {
|
||||
await window.getByRole('button', { name: '粘贴文本' }).click()
|
||||
await window.getByRole('button', { name: side === 'left' ? '粘贴到左侧' : '粘贴到右侧' }).click()
|
||||
await window.getByPlaceholder(/粘贴或输入/).fill(text)
|
||||
await window.getByRole('button', { name: '开始对比' }).click()
|
||||
}
|
||||
Reference in New Issue
Block a user