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('') 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() }) })