Files
DiffLens/e2e/app.spec.ts
T

465 lines
21 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { readFile } from 'fs/promises'
import { join } from 'path'
import {
launchApp,
stubOpenDialogSeq,
stubSaveDialog,
makeTmpDir,
removeTmpDir,
writeTextFixture,
pasteText,
dropFiles
} 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('字符级大输入:重算期间滚动位置不回弹(0.6.3 回退缺陷的回归锚)', async () => {
// fixture 严格对齐用户实测场景:真实文件打开 + 行数不等(左 950 / 右 942:改 3 + 删 20 + 插 12),
// 字符量约 4.7 万 > 重输入阈值(5000)→ 开字符级即进 worker 重路径
const dir = await makeTmpDir('difflens-e2e-scroll-')
try {
const pad = (i: number): string => String(i).padStart(4, '0')
const left = Array.from({ length: 950 }, (_, i) => `L${pad(i)}-abcdefghijklmnopqrstuvwxyz`)
const right = [...left]
right[300] = 'L0300-abcdefghijXlmnopqrstuvwxyz'
right.splice(530, 20)
right.splice(812, 0, ...Array.from({ length: 12 }, (_, k) => `N${pad(k)}-newcontent-${k}0987654321`))
const lp = await writeTextFixture(dir, 'left.txt', left.join('\n'))
const rp = await writeTextFixture(dir, 'right.txt', right.join('\n'))
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[lp], [rp]])
await window.getByRole('button', { name: '打开左侧' }).click()
await window.getByText('L0000-abcdefghijklmnopqrstuvwxyz').first().waitFor()
await window.getByRole('button', { name: '打开右侧' }).click()
await window.getByText('L0001-abcdefghijklmnopqrstuvwxyz').first().waitFor()
// 开字符级(首次进入重路径:快路径结果兜底撑住容器高度,不塌陷归零)
await window.getByLabel('字符级对比').check()
await window.getByText('有差异').first().waitFor()
// 滚到中部后二次触发重算(切选项):computing 期间位置必须保持
const scrollSel = '.pane-left .diff-scroll'
await window.evaluate((s) => {
const el = document.querySelector(s)
el.scrollTop = 5000
el.dispatchEvent(new Event('scroll'))
}, scrollSel)
expect(await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)).toBe(5000)
await window.getByLabel('忽略大小写').check()
await window.waitForTimeout(150)
// 关键断言:重算期间 scrollTop 不被钳制归零(缺陷版本会多级下坠到 0)
const during = await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)
expect(during).toBeGreaterThanOrEqual(4000)
// 重算完成后位置仍稳定
await window.getByText('有差异').first().waitFor()
await window.waitForTimeout(300)
expect(await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)).toBe(5000)
// 滚到底部验证内容连续无重复渲染错乱
await window.evaluate((s) => {
const el = document.querySelector(s)
el.scrollTop = el.scrollHeight
el.dispatchEvent(new Event('scroll'))
}, scrollSel)
await window.waitForTimeout(300)
const lastRow = await window.evaluate((s) => {
const sc = document.querySelector(s)
const rows = sc.querySelectorAll('.diff-row')
return rows.length > 0 ? rows[rows.length - 1].textContent : ''
}, scrollSel)
expect(lastRow).toContain('L0949')
await app.close()
} finally {
await removeTmpDir(dir)
}
})
})
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')).toBeVisible()
await expect(window.getByText('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('增量重扫:修改文件后点重新扫描,状态正确更新', async () => {
const root = await makeTmpDir('difflens-e2e-rescan-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
await wf(join(leftDir, 'diff.txt'), 'left v1')
await wf(join(rightDir, 'diff.txt'), 'right v1')
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('不同 1')).toBeVisible()
// 修改右侧 same.txt(真实 mtime 变化)→ 重新扫描(增量)→ 判定更新
await wf(join(rightDir, 'same.txt'), 'identical changed')
await window.getByRole('button', { name: '重新扫描' }).click()
await expect(window.getByText('相同 0')).toBeVisible()
await expect(window.getByText('不同 2')).toBeVisible()
// 改回一致 → 再扫 → 恢复
await wf(join(rightDir, 'same.txt'), 'identical')
await window.getByRole('button', { name: '重新扫描' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('语义判等、树形折叠、搜索与文件夹报告导出', async () => {
const root = await makeTmpDir('difflens-e2e-folder2-')
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 })
// 仅换行符差异(左 CRLF 右 LF)→ 语义同
await wf(join(leftDir, 'crlf.txt'), 'line1\r\nline2\r\n')
await wf(join(rightDir, 'crlf.txt'), 'line1\nline2\n')
// 实质内容差异 → 不同
await wf(join(leftDir, 'real.txt'), 'value = 1\n')
await wf(join(rightDir, 'real.txt'), 'value = 2\n')
// 完全一致(子目录)
await wf(join(leftDir, 'sub', 'same.txt'), 'identical')
await wf(join(rightDir, 'sub', 'same.txt'), 'identical')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
// 语义判等默认开:crlf.txt 计为语义同(统计徽章)
await expect(window.getByText('语义同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible()
// 默认仅看差异:语义同被过滤,只剩 real.txt
await expect(window.getByText('real.txt')).toBeVisible()
await expect(window.getByText('crlf.txt')).toHaveCount(0)
// 关闭仅看差异:树形展示(sub 目录行 + 语义同条目标注)
await window.getByLabel('仅看差异').uncheck()
await expect(window.getByText('crlf.txt')).toBeVisible()
await expect(window.getByText('sub')).toBeVisible()
await expect(window.getByText('same.txt')).toBeVisible()
// 目录折叠/展开
await window.getByText('sub').click()
await expect(window.getByText('same.txt')).toHaveCount(0)
await window.getByText('sub').click()
await expect(window.getByText('same.txt')).toBeVisible()
// 搜索过滤
await window.getByPlaceholder('搜索文件名…').fill('crlf')
await expect(window.getByText('crlf.txt')).toBeVisible()
await expect(window.getByText('real.txt')).toHaveCount(0)
await window.getByPlaceholder('搜索文件名…').fill('')
// 导出文件夹报告(真实写盘)
const savePath = join(root, 'folder-report.txt')
await stubSaveDialog(app, savePath)
await window.getByRole('button', { name: '导出报告' }).click()
await window.getByRole('button', { name: '纯文本' }).click()
await expect(window.getByText(/报告已保存/)).toBeVisible()
const txt = await readFile(savePath, 'utf-8')
expect(txt).toContain('DiffLens 文件夹对比报告')
expect(txt).toContain('判定口径:语义判等开启')
expect(txt).toContain('共 1 个文件存在差异')
expect(txt).toContain('语义同')
await app.close()
} finally {
await removeTmpDir(root)
}
})
})
test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => {
test('空面板拖入文件:内容渲染与文件名显示', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'dropped-left.txt', content: 'drag line 1\ndrag line 2' }
])
// 文件名出现在面板头,内容渲染(file.arrayBuffer → decodeBuffer IPC → 编码探测全链路)
await expect(window.locator('.pane-file').filter({ hasText: 'dropped-left.txt' })).toBeVisible()
await expect(window.getByText('drag line 1').first()).toBeVisible()
await app.close()
})
test('双侧拖入形成对比:差异渲染', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'a.txt', content: 'same\nleft-unique' }
])
// 左侧装载后进入对比视图:右侧为 DiffView 面板(.pane-right),不再是空态
await dropFiles(window, '.pane-right', [
{ name: 'b.txt', content: 'same\nright-unique' }
])
await expect(window.getByText('有差异')).toBeVisible()
await expect(window.getByText('left-unique').first()).toBeVisible()
await expect(window.getByText('right-unique').first()).toBeVisible()
await app.close()
})
test('拖入非文本扩展名被拒绝并提示', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'image.png', content: 'fake-binary', type: 'image/png' }
])
await expect(window.getByText(/仅支持文本文件:image\.png/)).toBeVisible()
// 保持空态(无导出入口)
await expect(window.getByRole('button', { name: '导出报告' })).toHaveCount(0)
await app.close()
})
test('多文件拖入提示已取第一个并加载首个文件', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'first.txt', content: 'first content' },
{ name: 'second.txt', content: 'second content' }
])
await expect(window.getByText(/一次只能导入一个文件/)).toBeVisible()
await expect(
window.locator('.pane-file').filter({ hasText: 'first.txt' })
).toBeVisible()
await app.close()
})
})
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()
})
})