From b5ff1b0ce8d38b8a8dfdd6acf7b3f36c3fa2e272 Mon Sep 17 00:00:00 2001
From: Hoang Pham
Date: Wed, 2 Sep 2026 02:34:55 +0700
Subject: [PATCH] feat(comparison): compare Markdown source
Assisted-by: OpenAI Codex:gpt-5.6-sol
Signed-off-by: Hoang Pham
---
.github/workflows/playwright.yml | 8 +-
package-lock.json | 32 +-
package.json | 2 +
playwright.config.ts | 101 ++-
playwright/comparison/comparison.spec.ts | 792 ++++++++++++++++++
playwright/comparison/fixtures.ts | 26 +
.../comparison/support/comparisonHarness.ts | 335 ++++++++
src/comparison/markdownSourceComparison.ts | 526 ++++++++++++
.../markdownSourceComparison.worker.ts | 12 +
.../markdownSourceComparisonProtocol.ts | 26 +
src/components/MarkdownContentComparison.vue | 49 +-
src/components/MarkdownSourceComparison.vue | 527 ++++++++++++
.../MarkdownSourceComponent.spec.ts | 122 ++-
.../createMarkdownContentComparison.spec.ts | 20 +
.../markdownSourceComparison.spec.ts | 214 +++++
.../markdownSourceComparisonWorker.spec.ts | 30 +
src/tests/playwrightConfig.spec.ts | 76 ++
vite.config.ts | 9 +
18 files changed, 2871 insertions(+), 36 deletions(-)
create mode 100644 playwright/comparison/comparison.spec.ts
create mode 100644 playwright/comparison/fixtures.ts
create mode 100644 playwright/comparison/support/comparisonHarness.ts
create mode 100644 src/comparison/markdownSourceComparison.ts
create mode 100644 src/comparison/markdownSourceComparison.worker.ts
create mode 100644 src/comparison/markdownSourceComparisonProtocol.ts
create mode 100644 src/components/MarkdownSourceComparison.vue
create mode 100644 src/tests/comparison/markdownSourceComparison.spec.ts
create mode 100644 src/tests/comparison/markdownSourceComparisonWorker.spec.ts
create mode 100644 src/tests/playwrightConfig.spec.ts
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 1de225766f6..1272f3fb083 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -55,17 +55,21 @@ jobs:
npm run build --if-present
- name: Install Playwright Browsers
- run: npx playwright install chromium --only-shell
+ run: npx playwright install --with-deps chromium webkit
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+ env:
+ TEXT_COMPARISON_E2E: '1'
- name: Upload results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: playwright-report_shard${{ matrix.shardIndex }}
- path: test-results/
+ path: |
+ test-results/
+ blob-report/
retention-days: 7
summary:
diff --git a/package-lock.json b/package-lock.json
index 267fcfc9bdb..446355a71a4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -61,6 +61,7 @@
"@tiptap/vue-3": "^3.30.5",
"@vueuse/shared": "^14.4.0",
"debounce": "^3.0.0",
+ "diff": "^8.0.4",
"escape-html": "^1.0.3",
"highlight.js": "^11.12.0",
"katex": "^0.18.4",
@@ -90,6 +91,7 @@
"yjs": "^13.6.32"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
"@nextcloud/babel-config": "^1.3.0",
"@nextcloud/browserslist-config": "^3.1.2",
"@nextcloud/e2e-test-server": "^0.5.1",
@@ -242,6 +244,19 @@
"node": "20 || >=22"
}
},
+ "node_modules/@axe-core/playwright": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
+ "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "axe-core": "~4.13.0"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -7311,6 +7326,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/axe-core": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
+ "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
@@ -9718,10 +9743,9 @@
}
},
"node_modules/diff": {
- "version": "8.0.3",
- "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
- "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
- "dev": true,
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
+ "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
diff --git a/package.json b/package.json
index 2b02748eab7..aec5de389ef 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
"@tiptap/vue-3": "^3.30.5",
"@vueuse/shared": "^14.4.0",
"debounce": "^3.0.0",
+ "diff": "^8.0.4",
"escape-html": "^1.0.3",
"highlight.js": "^11.12.0",
"katex": "^0.18.4",
@@ -108,6 +109,7 @@
"yjs": "^13.6.32"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
"@nextcloud/babel-config": "^1.3.0",
"@nextcloud/browserslist-config": "^3.1.2",
"@nextcloud/e2e-test-server": "^0.5.1",
diff --git a/playwright.config.ts b/playwright.config.ts
index d16c75500fb..f62d2c65b2b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,11 +2,57 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable jsdoc/require-jsdoc */
import type { ReporterDescription } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test'
+const COMPARISON_E2E = process.env.TEXT_COMPARISON_E2E === '1'
+const COMPARISON_TESTS = /playwright\/comparison\/.*\.spec\.ts/
+const COMPARISON_BASE_URL = process.env.TEXT_COMPARISON_BASE_URL || process.env.baseURL || 'http://localhost:8089/index.php/'
+const EXTERNAL_COMPARISON_SERVER = Boolean(process.env.TEXT_COMPARISON_BASE_URL || process.env.baseURL)
+
+function comparisonProjects() {
+ if (!COMPARISON_E2E) {
+ return []
+ }
+ return [{
+ name: 'comparison-chromium',
+ testMatch: COMPARISON_TESTS,
+ grepInvert: /@memory/,
+ use: {
+ ...devices['Desktop Chrome'],
+ baseURL: COMPARISON_BASE_URL,
+ ignoreHTTPSErrors: true,
+ screenshot: 'only-on-failure' as const,
+ trace: 'retain-on-failure' as const,
+ },
+ }, {
+ name: 'comparison-webkit',
+ testMatch: COMPARISON_TESTS,
+ grepInvert: /@memory/,
+ use: {
+ ...devices['Desktop Safari'],
+ baseURL: COMPARISON_BASE_URL,
+ ignoreHTTPSErrors: true,
+ screenshot: 'only-on-failure' as const,
+ trace: 'retain-on-failure' as const,
+ },
+ }, {
+ name: 'comparison-chromium-memory',
+ testMatch: COMPARISON_TESTS,
+ grep: /@memory/,
+ use: {
+ ...devices['Desktop Chrome'],
+ baseURL: COMPARISON_BASE_URL,
+ ignoreHTTPSErrors: true,
+ screenshot: 'only-on-failure' as const,
+ trace: 'retain-on-failure' as const,
+ },
+ }]
+}
+
/**
* Used locally - i.e. if `CI` is not set as an environment variable.
*/
@@ -24,22 +70,48 @@ const CI_CONFIG = {
// blob (so we can merge reports and download them for inspection),
// dot (so we have a quick overview in the logs while the tests are running)
// github (to have annotations in the PR)
- reporter: [['blob'], ['line'], ['github']] as ReporterDescription[],
+ reporter: [
+ ['blob'],
+ ['json', { outputFile: 'test-results/results.json' }],
+ ['line'],
+ ['github'],
+ ] as ReporterDescription[],
retries: 1,
timeout: 45_000,
// we shard to speed up the tests so no parallelism in workers
workers: 1,
} as const
+function comparisonWebServer() {
+ if (EXTERNAL_COMPARISON_SERVER) {
+ return undefined
+ }
+ return {
+ command: 'npm run start:nextcloud',
+ gracefulShutdown: {
+ signal: 'SIGTERM' as const,
+ timeout: 10000,
+ },
+ reuseExistingServer: false,
+ stderr: 'pipe' as const,
+ stdout: 'pipe' as const,
+ timeout: 5 * 60 * 1000,
+ wait: {
+ stdout: /Nextcloud is now ready to use/,
+ },
+ }
+}
+
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './playwright',
...(process.env.CI ? CI_CONFIG : LOCAL_CONFIG),
+ workers: COMPARISON_E2E ? 1 : undefined,
use: {
// Base URL to use in actions like `await page.goto('./')`.
- baseURL: process.env.baseURL ?? 'http://localhost:8089/index.php/',
+ baseURL: COMPARISON_BASE_URL,
// record traces but only keep them when the test fails
trace: 'on-first-retry',
},
@@ -47,32 +119,13 @@ export default defineConfig({
projects: [
{
name: 'chromium',
+ testIgnore: COMPARISON_TESTS,
use: {
...devices['Desktop Chrome'],
},
},
+ ...comparisonProjects(),
],
- webServer: {
- // Don't set `url` as it would take precedence over `wait.stdout` and tests start too early
- // url: 'http://127.0.0.1:8089',
- // Starts the Nextcloud docker container
- command: 'npm run start:nextcloud',
- // we use sigterm to notify the script to stop the container
- // if it does not respond, we force kill it after 10 seconds
- gracefulShutdown: {
- signal: 'SIGTERM',
- timeout: 10000,
- },
- // `start-nextcloud-server.mjs` only starts the server if not reachable yet.
- reuseExistingServer: false,
- stderr: 'pipe',
- stdout: 'pipe',
- // max. 5 minutes for creating the container
- timeout: 5 * 60 * 1000,
- wait: {
- // we wait for this line to appear in the output of the webserver until consider it done
- stdout: /Nextcloud is now ready to use/,
- },
- },
+ webServer: comparisonWebServer(),
})
diff --git a/playwright/comparison/comparison.spec.ts b/playwright/comparison/comparison.spec.ts
new file mode 100644
index 00000000000..2e4eab7f681
--- /dev/null
+++ b/playwright/comparison/comparison.spec.ts
@@ -0,0 +1,792 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { CDPSession, Page, TestInfo } from '@playwright/test'
+import type { ComparisonContents, ComparisonHarness, ComparisonMeasurement } from './support/comparisonHarness.ts'
+
+import { expect, test } from './fixtures.ts'
+
+const CELL_LEDGER = 40_000
+const MAXIMUM_SQUARE_AXIS = Math.floor(Math.sqrt(CELL_LEDGER))
+const CORE_LOGO = '/core/img/logo/logo.svg'
+const HIGH_CARDINALITY_CHANGES = 6_490
+const MAXIMUM_HIGH_CARDINALITY_HEAP_DELTA = 256_000_000
+
+const headingReplacement: ComparisonContents = {
+ before: 'A semantic block',
+ after: '# A semantic block',
+}
+const retainedTableCellEdit: ComparisonContents = {
+ before: '| Name | Value |\n| --- | --- |\n| retained | old value |\n| stable | same |',
+ after: '| Name | Value |\n| --- | --- |\n| retained | new value |\n| stable | same |',
+}
+
+test.describe('Text comparison production bundle acceptance', () => {
+ test('A12: the largest admitted square gap remains precise', async ({ comparison, page }) => {
+ test.setTimeout(180_000)
+ await mountMaximumSquare(comparison)
+
+ await expect(page.locator('.text-comparison > [data-comparison-source-fallback]')).toHaveCount(0)
+ await expect(page.locator('[data-comparison-select]')).toHaveCount(80)
+ await expect(page.getByRole('navigation', { name: 'Change pages' })).toContainText(`of ${MAXIMUM_SQUARE_AXIS}`)
+ })
+
+ test('T07: one edited retained table column is precise at cell altitude', async ({ comparison, page }) => {
+ await comparison.mount(retainedTableCellEdit)
+
+ const change = page.locator('[data-comparison-select]')
+ await expect(change).toHaveCount(1)
+ await change.click()
+ const changedCells = page.locator('td.text-comparison-change, td .text-comparison-change')
+ await expect(changedCells).toHaveCount(2)
+ await expect(page.locator('.text-comparison__document--before td').filter({ hasText: 'old value' })).toContainText('old value')
+ await expect(page.locator('.text-comparison__document--after td').filter({ hasText: 'new value' })).toContainText('new value')
+ })
+
+ test('T18: a later over-budget table coarsens without corrupting the admitted table plan', async ({ comparison, page }) => {
+ test.setTimeout(180_000)
+ await comparison.mount(tableLedgerFixture())
+
+ const changes = page.locator('[data-comparison-select]')
+ const pages = page.getByRole('navigation', { name: 'Change pages' })
+ await expect(changes).toHaveCount(80)
+ await expect(pages).toContainText('of 201')
+ await pages.getByRole('button', { name: 'Next' }).click()
+ await pages.getByRole('button', { name: 'Next' }).click()
+ await expect(changes).toHaveCount(41)
+ await expect(changes.last()).toContainText(/Structure changed|Table changed/)
+ await changes.last().click()
+ const current = page.locator('[data-comparison-change][aria-current="true"]')
+ await expect(current).toHaveCount(2)
+ expect(await current.evaluateAll((elements) => elements.every((element) => element.closest('table') === element.parentElement?.closest('table')))).toBe(true)
+ })
+
+ test('V01: one first-class edit owns one row, ordinal, identity, and complete target set', async ({ comparison, page }) => {
+ await comparison.mount(headingReplacement)
+
+ const row = page.locator('[data-comparison-select]')
+ await expect(row).toHaveCount(1)
+ await expect(row).toHaveAttribute('aria-current', 'true')
+ await expect(row).toHaveAttribute('aria-label', /Changed|Heading|Structure/)
+ await expect(page.locator('.text-comparison__sr-only')).toContainText('Change 1 of 1')
+ await row.click()
+ const identities = await page.locator('.text-comparison__documents [data-comparison-change]').evaluateAll((elements) => (
+ [...new Set(elements.map((element) => element.getAttribute('data-comparison-change')))]
+ ))
+ expect(identities).toHaveLength(1)
+ await expect(page.locator('.text-comparison__documents [data-comparison-change]')).toHaveCount(2)
+ })
+
+ test('V02: filtering moves current selection next and then previous when needed', async ({ comparison, page }) => {
+ await assertFormattingFilterMove(comparison, page, {
+ before: 'Old first.\n\nFormatting only.\n\nOld last.',
+ after: 'New first.\n\n**Formatting only.**\n\nNew last.',
+ }, 'next')
+ await comparison.destroy()
+ await assertFormattingFilterMove(comparison, page, {
+ before: 'Old first.\n\nFormatting only.',
+ after: 'New first.\n\n**Formatting only.**',
+ }, 'previous')
+ })
+
+ test('V03: selecting a Changes row activates the identical edit in both Documents panes', async ({ comparison, page }) => {
+ await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.' })
+
+ const selectedEdit = page.locator('[data-comparison-select]').nth(1)
+ await selectedEdit.click()
+ const currentBySide: string[] = []
+ for (const side of ['before', 'after']) {
+ const current = page.locator(`.text-comparison__document--${side} [data-comparison-change][aria-current="true"]`)
+ await expect(current).toHaveCount(1)
+ currentBySide.push((await current.getAttribute('data-comparison-change')) ?? '')
+ }
+ expect(currentBySide[0]).toBe(currentBySide[1])
+ })
+
+ test('V04: an empty-side change has no synthetic marker and remains navigable', async ({ comparison, page }) => {
+ await comparison.mount({ before: '# Removed first\n\n# Removed second', after: '' })
+
+ await page.locator('[data-comparison-select]').first().click()
+ await expect(page.locator('.text-comparison__document--after [data-comparison-change]')).toHaveCount(0)
+ await expect(page.locator('.text-comparison__document--after [data-comparison-placeholder], .text-comparison__document--after .text-comparison-placeholder')).toHaveCount(0)
+ const announcement = page.locator('.text-comparison__sr-only')
+ const firstAnnouncement = await announcement.textContent()
+ await page.getByRole('button', { name: 'Next' }).click()
+ await expect(announcement).not.toHaveText(firstAnnouncement ?? '')
+ await expect(page.locator('.text-comparison__document--before [data-comparison-change][aria-current="true"]')).toHaveCount(1)
+ })
+
+ test('V05: paired Documents panes preserve independent scroll positions', async ({ comparison, page }) => {
+ const paragraphs = Array.from({ length: 100 }, (_, index) => `Paragraph ${index}.`).join('\n\n')
+ await comparison.mount({ before: `Old first.\n\n${paragraphs}\n\nOld tail.`, after: `New first.\n\n${paragraphs}\n\nNew tail.` })
+
+ await page.locator('[data-comparison-select]').first().click()
+ const beforeScroller = page.locator('.text-comparison__document--before .text-comparison__document-scroller')
+ const afterScroller = page.locator('.text-comparison__document--after .text-comparison__document-scroller')
+ await beforeScroller.evaluate((element) => {
+ element.scrollTop = 120
+ })
+ await afterScroller.evaluate((element) => {
+ element.scrollTop = 360
+ })
+ expect(await beforeScroller.evaluate(({ scrollTop }) => scrollTop)).not.toBe(await afterScroller.evaluate(({ scrollTop }) => scrollTop))
+ await page.getByRole('button', { name: 'Next' }).click()
+ expect(await beforeScroller.evaluate(({ scrollTop }) => scrollTop)).not.toBe(await afterScroller.evaluate(({ scrollTop }) => scrollTop))
+ })
+
+ test('V06: responsive single-pane Documents retain side and selection state', async ({ comparison, page }) => {
+ await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.', width: 620 })
+
+ await page.locator('[data-comparison-select]').nth(1).click()
+ await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--single/)
+ const sideTabs = page.getByRole('tablist', { name: 'Version to display' })
+ const beforeCurrent = page.locator('.text-comparison__document--before [data-comparison-change][aria-current="true"]')
+ await expect(beforeCurrent).toBeVisible()
+ const identity = await beforeCurrent.getAttribute('data-comparison-change')
+ await sideTabs.getByRole('tab', { name: 'After' }).click()
+ await expect(sideTabs.getByRole('tab', { name: 'After' })).toHaveAttribute('aria-selected', 'true')
+ const afterCurrent = page.locator('.text-comparison__document--after [data-comparison-change][aria-current="true"]')
+ await expect(afterCurrent).toBeVisible()
+ await expect(afterCurrent).toHaveAttribute('data-comparison-change', identity ?? '')
+ await page.locator('#text-comparison-harness').evaluate((element: HTMLElement) => {
+ element.style.inlineSize = '390px'
+ })
+ await expect.poll(() => page.locator('.toolbar').evaluate(({ clientWidth, scrollWidth }) => scrollWidth <= clientWidth)).toBe(true)
+ const viewTabsTop = await page.getByRole('tablist', { name: 'Comparison view' }).evaluate((element) => (element as HTMLElement).offsetTop)
+ const navigationTop = await page.getByLabel('Change navigation').evaluate((element) => (element as HTMLElement).offsetTop)
+ expect(navigationTop).toBeGreaterThan(viewTabsTop)
+ })
+
+ test('AUD-24: narrow Documents show the side that contains a one-sided edit', async ({ comparison, page }) => {
+ await comparison.mount({ before: '', after: '# Added first\n\n# Added second', width: 620 })
+
+ await page.locator('[data-comparison-select]').first().click()
+ const sideTabs = page.getByRole('tablist', { name: 'Version to display' })
+ await expect(sideTabs.getByRole('tab', { name: 'After' })).toHaveAttribute('aria-selected', 'true')
+ await expect(page.locator('.text-comparison__document--after [data-comparison-change][aria-current="true"]')).toBeVisible()
+ await expect(page.locator('.text-comparison__document--before')).toBeHidden()
+
+ await comparison.destroy()
+ await comparison.mount({ before: '# Removed first\n\n# Removed second', after: '', width: 620 })
+ await page.locator('[data-comparison-select]').first().click()
+ const deletionTabs = page.getByRole('tablist', { name: 'Version to display' })
+ await deletionTabs.getByRole('tab', { name: 'After' }).click()
+ await page.getByRole('tab', { name: 'Changes' }).click()
+ await page.locator('[data-comparison-select]').nth(1).click()
+ await expect(deletionTabs.getByRole('tab', { name: 'Before' })).toHaveAttribute('aria-selected', 'true')
+ await expect(page.locator('.text-comparison__document--before [data-comparison-change][aria-current="true"]')).toBeVisible()
+ await expect(page.locator('.text-comparison__document--after')).toBeHidden()
+ })
+
+ test('V06a: desktop comparison fills a flex mount host', async ({ comparison, page }) => {
+ await comparison.mount({ before: 'Old document.', after: 'New document.', width: 1100 })
+ await page.locator('#text-comparison-harness').evaluate((host) => {
+ host.style.display = 'flex'
+ })
+
+ await expect(page.locator('.text-comparison-root')).toHaveCSS('width', '1100px')
+ await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--paired/)
+ })
+
+ test('V06b: desktop Changes rows keep the reviewed full-width list presentation', async ({ comparison, page }) => {
+ await comparison.mount({ before: '# Old heading\n\nOld paragraph.', after: '# New heading\n\nNew paragraph.', width: 1100 })
+
+ const host = page.locator('#text-comparison-harness')
+ const section = page.locator('.text-comparison__section-toggle').first()
+ const row = page.locator('[data-comparison-select]').first()
+ const [hostBox, sectionBox, rowBox] = await Promise.all([host.boundingBox(), section.boundingBox(), row.boundingBox()])
+ expect(hostBox).not.toBeNull()
+ expect(sectionBox).not.toBeNull()
+ expect(rowBox).not.toBeNull()
+ expect(rowBox!.width).toBeGreaterThanOrEqual(900)
+ expect(sectionBox!.width).toBe(rowBox!.width)
+ expect(Math.abs(rowBox!.x + rowBox!.width / 2 - (hostBox!.x + hostBox!.width / 2))).toBeLessThan(2)
+ await expect(section).toHaveCSS('border-radius', '0px')
+ await expect(row).toHaveCSS('border-radius', '0px')
+ })
+
+ test('V07: tabs, navigation, focus, and announcements expose accessible state', async ({ comparison, page }) => {
+ await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.' })
+ await comparison.assertAccessibleComparison()
+
+ await page.locator('[data-comparison-select]').first().click()
+ const announcement = page.locator('.text-comparison__sr-only')
+ const initialAnnouncement = await announcement.textContent()
+ await page.getByRole('button', { name: 'Next' }).click()
+ await expect(announcement).not.toHaveText(initialAnnouncement ?? '')
+ await page.getByRole('button', { name: 'Previous' }).click()
+ await expect(announcement).toHaveText(initialAnnouncement ?? '')
+ const documentsTab = page.getByRole('tab', { name: 'Full documents' })
+ await documentsTab.press('End')
+ await expect(page.getByRole('tab', { name: 'Markdown source' })).toBeFocused()
+ await page.getByRole('tab', { name: 'Markdown source' }).press('Home')
+ await expect(page.getByRole('tab', { name: 'Changes' })).toBeFocused()
+ await expect(announcement).toHaveAttribute('aria-live', 'polite')
+ await expect(announcement).toHaveAttribute('aria-atomic', 'true')
+ })
+
+ test('V08: a changed real image node-view receives scoped visible treatment', async ({ comparison, page }, testInfo) => {
+ await comparison.mount({
+ before: `\n\nOld paragraph.`,
+ after: `\n\nNew paragraph.`,
+ })
+ const changes = page.locator('[data-comparison-select]')
+ await expect(changes).toHaveCount(2)
+ await changes.first().click()
+
+ const wrappers = page.locator('[data-node-view-wrapper].text-comparison-change:has(img)')
+ await expect(wrappers).toHaveCount(2)
+ for (const wrapper of await wrappers.all()) {
+ await expect(wrapper.locator('figure[data-component="image-view"] img')).toHaveCount(1)
+ await expect(wrapper).toHaveClass(/text-comparison-change--current/)
+ const boxShadow = await wrapper.evaluate((element) => getComputedStyle(element).boxShadow)
+ expect(boxShadow).not.toBe('none')
+ expect(boxShadow).toMatch(/inset/)
+ }
+ await testInfo.attach('real-image-node-view.png', {
+ body: await page.locator('#text-comparison-harness').screenshot(),
+ contentType: 'image/png',
+ })
+
+ await page.getByRole('button', { name: 'Next' }).click()
+ for (const wrapper of await wrappers.all()) {
+ await expect(wrapper).not.toHaveClass(/text-comparison-change--current/)
+ const boxShadow = await wrapper.evaluate((element) => getComputedStyle(element).boxShadow)
+ expect(boxShadow).not.toBe('none')
+ expect(boxShadow).toMatch(/inset/)
+ }
+
+ await comparison.destroy()
+ await comparison.mount({
+ before: '| Name |\n| --- |\n| retained |\n| removed |',
+ after: '| Name |\n| --- |\n| retained |',
+ })
+ await page.locator('[data-comparison-select]').click()
+ const structuralRow = page.locator('tr.text-comparison-change')
+ await expect(structuralRow).toHaveCount(1)
+ const structuralTreatment = await structuralRow.evaluate((element) => getComputedStyle(element).boxShadow)
+ expect(structuralTreatment).not.toBe('none')
+ expect(structuralTreatment).toMatch(/inset/)
+ })
+
+ test('V09: syntax-only Markdown reports no semantic edit and opens Source', async ({ comparison, page }) => {
+ await comparison.mount({ before: '*same rendered text*', after: '_same rendered text_' })
+
+ await expect(page.getByRole('status')).toContainText('No rendered differences')
+ await expect(page.locator('[data-comparison-select]')).toHaveCount(0)
+ await page.getByRole('button', { name: 'Open Markdown source' }).click()
+ await expect(page.getByRole('tab', { name: 'Markdown source' })).toHaveAttribute('aria-selected', 'true')
+ await expect(page.locator('[data-source-hunk]')).toHaveCount(1)
+ })
+
+ test('V10: Source preserves literal EOL, tab, trailing-space, control, and final-newline differences', async ({ comparison, page }) => {
+ await comparison.mount({ before: 'first\tline \r\nzero\u200Bwidth', after: 'first\tline \nzero\u200Cwidth\n' })
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+
+ const source = page.locator('.text-source-comparison')
+ for (const token of ['TAB', 'ZWSP', 'ZWNJ', 'CRLF', 'LF', 'TRAILING SPACE', 'No newline at end of file']) {
+ await expect(source).toContainText(token)
+ }
+ const sourceText = await source.textContent()
+ expect(sourceText).not.toContain('\u200B')
+ expect(sourceText).not.toContain('\u200C')
+ })
+
+ test('V11: Source processing limits retain complete before and after fallback text', async ({ comparison, page }) => {
+ const before = Array.from({ length: 3000 }, (_, index) => `before-${index}`).join('\n')
+ const after = Array.from({ length: 3000 }, (_, index) => `after-${index}`).join('\n')
+ await comparison.mount({ before, after })
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+
+ await expect(page.locator('[data-source-limited]')).toBeVisible()
+ const fallback = page.locator('[data-comparison-source-fallback]')
+ await expect(fallback).toContainText('before-0')
+ await expect(fallback).toContainText('before-2999')
+ await expect(fallback).toContainText('after-0')
+ await expect(fallback).toContainText('after-2999')
+ })
+
+ test('V12: repeated idempotent destroy leaves no editors, observers, or root DOM', async ({ comparison, page }) => {
+ await comparison.open()
+ const baseline = await comparison.observerCounts()
+
+ for (let iteration = 0; iteration < 2; iteration++) {
+ const measurement = await comparison.mount({ before: `Before ${iteration}`, after: `After ${iteration}` })
+ expect(measurement.rootCount).toBe(1)
+ expect(measurement.proseMirrorCount).toBe(0)
+ await page.getByRole('tab', { name: 'Full documents' }).click()
+ await expect(page.locator('.ProseMirror')).toHaveCount(2)
+ await comparison.destroy(2)
+ await expect(page.locator('#text-comparison-harness')).toBeEmpty()
+ expect(await comparison.observerCounts()).toEqual(baseline)
+ }
+ })
+
+ test('F05: editor initialization failure mounts complete literal Source for both snapshots', async ({ comparison, page }) => {
+ let fallbackChunkRequests = 0
+ await page.route('**/*MarkdownSourceFallback*', async (route) => {
+ fallbackChunkRequests++
+ await route.abort('failed')
+ })
+ await comparison.forceEditorInitializationFailure()
+ await comparison.mount({ before: 'complete before', after: 'complete after' })
+
+ const fallback = page.locator('[data-comparison-source-fallback]')
+ await expect(fallback).toContainText('complete before')
+ await expect(fallback).toContainText('complete after')
+ await expect(page.locator('.ProseMirror')).toHaveCount(0)
+ expect(fallbackChunkRequests).toBe(0)
+ })
+
+ test('F06: projection failure mounts Source without partial Documents', async ({ comparison, page }) => {
+ await comparison.forceProjectionFailure()
+ await comparison.mount({ before: 'Projection before', after: 'Projection after' })
+ await page.getByRole('tab', { name: 'Full documents' }).click()
+
+ const fallback = page.locator('[data-comparison-source-fallback]')
+ await expect(fallback).toContainText('Projection before')
+ await expect(fallback).toContainText('Projection after')
+ await expect(page.locator('.text-comparison__documents .ProseMirror')).toHaveCount(0)
+ await expect(page.locator('.text-comparison__documents [data-comparison-change]')).toHaveCount(0)
+ })
+
+ test('F11: normal comparison modes emit no unexplained browser or network failures', async ({ comparison, page }) => {
+ comparison.resetCapture()
+ await comparison.mount({ before: 'Old content.', after: '**New content.**' })
+ await page.locator('[data-comparison-select]').click()
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+ await expect(page.locator('[data-source-hunk]')).toBeVisible()
+
+ expect(comparison.failures).toEqual([])
+ expect(comparison.consoleMessages.filter(({ type }) => type === 'error')).toEqual([])
+ expect(comparison.network.filter(({ failure, status }) => failure || (status ?? 200) >= 400)).toEqual([])
+ })
+
+ test('P01: the near-line-floor one-change fixture stays precise with bounded readiness', async ({ comparison, page }, testInfo) => {
+ test.setTimeout(180_000)
+ const measurement = await comparison.mount(nearLineFloorFixture())
+
+ await expect(page.locator('[data-comparison-select]')).toHaveCount(1)
+ await attachMeasurement(testInfo, 'near-line-floor', measurement, { weightedDebit: 0 })
+ })
+
+ test('AUD-02: pre-mount selection and filtering initialize both Documents decoration plugins', async ({ comparison, page }) => {
+ for (const width of [1000, 620]) {
+ await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.', width })
+ await page.locator('[data-comparison-select]').nth(1).click()
+ for (const side of ['before', 'after']) {
+ await expect(page.locator(`.text-comparison__document--${side} [data-comparison-change="change-1"][aria-current="true"]`)).toHaveCount(1)
+ }
+ await comparison.destroy()
+
+ await comparison.mount({ before: 'Formatting only.\n\nOld content.', after: '**Formatting only.**\n\nNew content.', width })
+ await page.getByRole('checkbox', { name: 'Hide formatting-only changes' }).check()
+ await page.getByRole('tab', { name: 'Full documents' }).click()
+ await expect(page.locator('.text-comparison__documents .text-comparison-change--formatting')).toHaveCount(0)
+ for (const side of ['before', 'after']) {
+ await expect(page.locator(`.text-comparison__document--${side} [data-comparison-change][aria-current="true"]`)).toHaveCount(1)
+ }
+ await comparison.destroy()
+ }
+ })
+
+ test('AUD-05: Source visibly exposes side, operation, whitespace, EOL, control, and final-newline semantics', async ({ comparison, page }) => {
+ await comparison.mount({ before: 'old\t\u200B\r\ntrail ', after: 'new\ntrail\n' })
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+
+ const source = page.locator('.text-source-comparison')
+ await expect(source.locator('[data-source-side="before"]')).toHaveText('Before')
+ await expect(source.locator('[data-source-side="after"]')).toHaveText('After')
+ await expect(source.locator('[data-source-operation="removed"]').first()).toHaveAttribute('aria-label', /Removed line/)
+ await expect(source.locator('[data-source-operation="added"]').first()).toHaveAttribute('aria-label', /Added line/)
+ await expect(source.locator('[data-source-operation="removed"] [data-source-cue]').first()).toHaveText('−')
+ await expect(source.locator('[data-source-operation="added"] [data-source-cue]').first()).toHaveText('+')
+ for (const token of ['TAB', 'ZWSP', 'CRLF', 'LF', '2 TRAILING SPACES', 'No newline at end of file']) {
+ await expect(source).toContainText(token)
+ }
+ })
+
+ test('AUD-09: settled image dialog focus is contained and restored on close', async ({ comparison, page }, testInfo) => {
+ await comparison.mount({ before: ``, after: `` })
+ await page.locator('[data-comparison-select]').first().click()
+ const action = page.getByRole('button', { name: 'Open image Before logo' })
+ await action.focus()
+ await action.press('Enter')
+
+ const dialog = page.getByRole('dialog')
+ await expect(dialog).toBeVisible()
+ await expect.poll(() => page.evaluate(() => document.querySelector('[role="dialog"]')?.contains(document.activeElement) ?? false)).toBe(true)
+ if (testInfo.project.name.includes('chromium')) {
+ await page.keyboard.press('Tab')
+ await expect.poll(() => page.evaluate(() => document.querySelector('[role="dialog"]')?.contains(document.activeElement) ?? false)).toBe(true)
+ }
+ await page.keyboard.press('Escape')
+ await expect(dialog).toBeHidden()
+ await expect(action).toBeFocused()
+ })
+
+ test('AUD-10: Changes tokens wrap with spacing and selected tabs have visible treatment', async ({ comparison, page }) => {
+ await comparison.mount({ before: 'A short value.', after: '**A substantially longer changed value that must remain readable.**' })
+
+ const item = page.locator('.text-comparison__change-item')
+ const content = item.locator('.text-comparison__change-item-content')
+ const itemStyle = await item.evaluate((element) => {
+ const style = getComputedStyle(element)
+ return { columnGap: style.columnGap, display: style.display, rowGap: style.rowGap }
+ })
+ const contentStyle = await content.evaluate((element) => {
+ const style = getComputedStyle(element)
+ return { display: style.display, gap: style.gap, overflowWrap: style.overflowWrap }
+ })
+ expect(itemStyle.display).toBe('grid')
+ expect(itemStyle.columnGap).not.toBe('0px')
+ expect(contentStyle.display).toBe('flex')
+ expect(contentStyle.gap).not.toBe('0px')
+ expect(contentStyle.overflowWrap).toBe('anywhere')
+
+ const selectedTab = page.getByRole('tab', { name: 'Changes' })
+ const selectedStyle = await selectedTab.evaluate((element) => {
+ const style = getComputedStyle(element)
+ return {
+ borderRadius: style.borderRadius,
+ borderWidth: style.borderBottomWidth,
+ boxShadow: style.boxShadow,
+ fontWeight: style.fontWeight,
+ }
+ })
+ expect(selectedStyle.borderRadius).toBe('0px')
+ expect(Number.parseFloat(selectedStyle.borderWidth)).toBeGreaterThan(0)
+ expect(selectedStyle.boxShadow).toBe('none')
+ expect(Number.parseInt(selectedStyle.fontWeight, 10)).toBeGreaterThanOrEqual(700)
+
+ await comparison.destroy()
+ await comparison.mount({
+ before: '# B1 duplicate-body deletion\n\n| A | B | C |\n| --- | --- | --- |\n| x | x | x |\n| x | x | x |',
+ after: '# B1 duplicate-body deletion\n\n| A | B |\n| --- | --- |\n| x | x |\n| x | x |',
+ width: 340,
+ })
+ await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--single/)
+ const narrowItem = page.locator('.text-comparison__change-item').first()
+ const narrowLabel = narrowItem.getByText('Table column removed', { exact: true })
+ await expect(narrowLabel).toBeVisible()
+ await expect(narrowItem.locator('.badge')).toHaveCount(2)
+ expect(await narrowLabel.evaluate((element) => {
+ const range = document.createRange()
+ range.selectNodeContents(element)
+ return range.getClientRects().length
+ })).toBe(1)
+ expect(await narrowItem.locator('.title').evaluate((element) => {
+ const bounds = element.getBoundingClientRect()
+ return [...element.children].every((child) => {
+ const rect = child.getBoundingClientRect()
+ return rect.left >= bounds.left && rect.right <= bounds.right
+ })
+ })).toBe(true)
+ expect(await narrowItem.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
+ })
+
+ test('AUD-11: revealing a responsive hidden side locates the already-current edit', async ({ comparison, page }) => {
+ const middle = Array.from({ length: 120 }, (_, index) => `Stable paragraph ${index}.`).join('\n\n')
+ await comparison.mount({ before: `Old first.\n\n${middle}\n\nOld tail.`, after: `New first.\n\n${middle}\n\nNew tail.`, width: 620, height: 360 })
+ await page.locator('[data-comparison-select]').nth(1).click()
+
+ await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--single/)
+ await expect(page.locator('.text-comparison__document--after')).toBeHidden()
+ const afterScroller = page.locator('.text-comparison__document--after .text-comparison__document-scroller')
+ await afterScroller.evaluate((element) => {
+ element.scrollTop = 0
+ })
+ await page.locator('#text-comparison-harness').evaluate((element: HTMLElement) => {
+ element.style.inlineSize = '900px'
+ })
+ await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--paired/)
+ await expect(page.locator('.text-comparison__document--after [data-comparison-change][aria-current="true"]')).toBeVisible()
+ await expect.poll(() => afterScroller.evaluate(({ scrollTop }) => scrollTop)).toBeGreaterThan(0)
+ })
+
+ test('AUD-13: audited bidi and control characters render only as visible inert tokens', async ({ comparison, page }) => {
+ const controls = '\u061C\u00AD\u200E\u200F\u0085\u2028\u2029'
+ await comparison.mount({ before: `old${controls}`, after: 'new\n' })
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+
+ const source = page.locator('.text-source-comparison')
+ for (const token of ['ALM', 'SHY', 'LRM', 'RLM', 'NEL', 'LS', 'PS']) {
+ await expect(source).toContainText(token)
+ }
+ const text = await source.textContent()
+ for (const control of controls) {
+ expect(text).not.toContain(control)
+ }
+ })
+
+ test('AUD-14: high-cardinality Changes, Documents, and Source stay within explicit budgets', { tag: '@memory' }, async ({ comparison, page }, testInfo) => {
+ test.setTimeout(180_000)
+ expect(testInfo.project.name).toBe('comparison-chromium-memory')
+ const cdp = await page.context().newCDPSession(page)
+ await cdp.send('Performance.enable')
+ const heapBeforeBytes = await readChromiumHeap(cdp)
+ const before = Array.from({ length: HIGH_CARDINALITY_CHANGES }, (_, index) => `# Removed section ${index}`).join('\n')
+ const measurement = await comparison.mount({ before, after: '' })
+
+ await expect(page.locator('[data-comparison-select]')).toHaveCount(80)
+ const changesDomCount = await page.locator('.text-comparison__changes *').count()
+ expect(changesDomCount).toBeLessThanOrEqual(1_500)
+
+ const documentsStarted = await page.evaluate(() => performance.now())
+ await page.getByRole('tab', { name: 'Full documents' }).click()
+ await expect(page.locator('.text-comparison__document--before h1')).toHaveCount(HIGH_CARDINALITY_CHANGES)
+ const documentsMilliseconds = await page.evaluate((started) => performance.now() - started, documentsStarted)
+ const documentsDomCount = await page.locator('.text-comparison__documents *').count()
+ expect(documentsDomCount).toBeLessThanOrEqual(20_000)
+
+ const sourceStarted = await page.evaluate(() => performance.now())
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+ const source = page.locator('[data-comparison-source-fallback]')
+ await expect(source).toBeVisible()
+ const sourceMilliseconds = await page.evaluate((started) => performance.now() - started, sourceStarted)
+ const sourceDomCount = await source.locator('*').count()
+ const sourceCharacters = await source.evaluate((element) => element.textContent?.length ?? 0)
+ expect(sourceDomCount).toBeLessThanOrEqual(50)
+ expect(sourceCharacters).toBeLessThanOrEqual(2_000_000)
+
+ const heapAfterModes = await readChromiumHeap(cdp)
+ const heapDeltaBytes = heapAfterModes - heapBeforeBytes
+ expect(heapDeltaBytes).toBeLessThan(MAXIMUM_HIGH_CARDINALITY_HEAP_DELTA)
+ await testInfo.attach('high-cardinality-metrics.json', {
+ body: Buffer.from(JSON.stringify({ changesDomCount, documentsDomCount, documentsMilliseconds, heapAfterModes, heapBeforeBytes, heapDeltaBytes, memoryMetric: 'chromium-cdp/Performance.JSHeapUsedSize', mountMilliseconds: measurement.durationMilliseconds, sourceCharacters, sourceDomCount, sourceMilliseconds }, null, 2)),
+ contentType: 'application/json',
+ })
+ })
+
+ test('AUD-18: read-only image action is named, focusable, rendered, and operable with Enter', async ({ comparison, page }) => {
+ await comparison.mount({ before: ``, after: `` })
+ await page.locator('[data-comparison-select]').first().click()
+ const action = page.getByRole('button', { name: 'Open image Before logo' })
+
+ await expect(action.locator('img')).toBeVisible()
+ await action.focus()
+ await expect(action).toBeFocused()
+ await action.press('Enter')
+ await expect(page.getByRole('dialog')).toBeVisible()
+ })
+
+ test('AUD-18: read-only attachment action retains its preview and operates with Space', async ({ comparison, page }) => {
+ const attachmentPath = '/Documents/document.pdf'
+ await page.route('**/apps/text/attachments', async (route) => route.fulfill({
+ json: [{ davPath: attachmentPath, fullUrl: '/document.pdf', isImage: false, metadata: null, mimetype: 'application/pdf', name: 'document.pdf', previewUrl: CORE_LOGO, size: 100 }],
+ }))
+ await page.evaluate(() => {
+ sessionStorage.removeItem('attachment-viewer-path')
+ Object.assign(window.OCA, {
+ Viewer: {
+ file: null,
+ mimetypes: ['application/pdf'],
+ open: ({ path }: { path: string }) => sessionStorage.setItem('attachment-viewer-path', path),
+ },
+ })
+ })
+ await comparison.mount({ before: '', after: '', fileId: 123 })
+ await page.locator('[data-comparison-select]').first().click()
+ const action = page.getByRole('button', { name: 'Open attachment Before document' })
+
+ await expect(action.locator('img')).toBeVisible()
+ await action.focus()
+ await expect(action).toBeFocused()
+ await action.press('Space')
+ await expect.poll(() => page.evaluate(() => sessionStorage.getItem('attachment-viewer-path'))).toBe(attachmentPath)
+ })
+
+ test('AUD-21: a rejected loaded callback settles once and keeps the comparison', async ({ comparison, page }) => {
+ const measurement = await comparison.mount({ before: 'Before callback.', after: 'After callback.', rejectLoaded: true })
+
+ expect(measurement.loadedCallbackCalls).toBe(1)
+ await expect(page.locator('.text-comparison')).toBeVisible()
+ await expect(page.locator('[data-comparison-source-fallback]')).toHaveCount(0)
+ await expect(page.locator('[data-comparison-select]')).toHaveCount(1)
+ await expect(page.locator('.ProseMirror')).toHaveCount(0)
+ })
+
+ test('AUD-22: complete Source fallback responds to host width instead of viewport width', async ({ comparison, page }) => {
+ const oversized = Array.from({ length: 6501 }, (_, index) => `line ${index}`).join('\n')
+ await page.setViewportSize({ width: 1280, height: 800 })
+
+ for (const [width, columns] of [[620, 1], [900, 2]] as const) {
+ await comparison.mount({ before: oversized, after: `${oversized}\nchanged`, width })
+ const documents = page.locator('.text-source-fallback__documents')
+ await expect(documents).toBeVisible()
+ const tracks = await documents.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length)
+ expect(tracks).toBe(columns)
+ await comparison.destroy()
+ }
+ })
+
+ test('oversized source lines fall back without blocking the page', async ({ comparison, page }) => {
+ const before = `# Oversized source fallback\n\n\`\`\`\n${'\t'.repeat(200_000)}😀\n\`\`\``
+ const after = `${before}\n\ncurrent marker`
+ await comparison.mount({ before, after })
+ await expect(page.locator('[data-comparison-source-fallback]')).toBeVisible()
+ await expect(page.getByText('Source preview was truncated to the display limit.')).toBeVisible()
+ })
+
+ test('AUD-23: Source rows and navigation retain deliberate geometry', async ({ comparison, page }) => {
+ const middle = Array.from({ length: 10 }, (_, index) => `stable ${index}`).join('\n')
+ await comparison.mount({ before: `stable first\r\nold value \r\n${middle}\r\nold tail`, after: `stable first\nnew value \n${middle}\nnew tail\n` })
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+
+ const source = page.locator('.text-source-comparison')
+ const changed = source.locator('[data-source-operation="removed"]').first()
+ const unchanged = source.locator('.text-source-comparison__line').filter({ hasText: 'stable first' }).first()
+ const changedGeometry = await changed.evaluate((element) => [...element.children].map((child) => {
+ const rect = child.getBoundingClientRect()
+ return { left: rect.left, top: rect.top }
+ }))
+ const unchangedGeometry = await unchanged.evaluate((element) => [...element.children].map((child) => child.getBoundingClientRect().left))
+ expect(changedGeometry).toHaveLength(5)
+ expect(new Set(changedGeometry.map(({ top }) => Math.round(top))).size).toBe(1)
+ expect(Math.round(changedGeometry[1]!.left)).toBe(Math.round(unchangedGeometry[0]!))
+ expect(Math.round(changedGeometry[2]!.left)).toBe(Math.round(unchangedGeometry[1]!))
+
+ const navigation = source.locator('.text-source-comparison__navigation')
+ const navigationCenters = await navigation.evaluate((element) => [...element.children].map((child) => {
+ const rect = child.getBoundingClientRect()
+ return rect.top + rect.height / 2
+ }))
+ expect(Math.max(...navigationCenters) - Math.min(...navigationCenters)).toBeLessThan(4)
+ for (const button of await navigation.getByRole('button').all()) {
+ await expect(button).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
+ }
+ const sourceFontSize = await source.evaluate((element) => getComputedStyle(element).fontSize)
+ await expect(source.locator('[data-source-side="before"]')).toHaveCSS('font-size', sourceFontSize)
+ await expect(source.locator('[data-source-side="after"]')).toHaveCSS('font-size', sourceFontSize)
+ await expect(source.locator('[data-source-hunk]').first().getByRole('button')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
+ await navigation.getByRole('button', { name: 'Next' }).click()
+ await expect(navigation).toContainText('Source change 2 of 2')
+
+ await comparison.destroy()
+ await comparison.mount({ before: `old value \r\n${middle}\r\nold tail`, after: `new value \n${middle}\nnew tail\n`, width: 390 })
+ await page.getByRole('tab', { name: 'Markdown source' }).click()
+ const narrowNavigation = source.locator('.text-source-comparison__navigation')
+ const narrowTops = await narrowNavigation.evaluate((element) => [...element.children].map((child) => Math.round(child.getBoundingClientRect().top)))
+ expect(narrowTops[0]).toBe(narrowTops[2])
+ expect(narrowTops[1]).toBeGreaterThan(narrowTops[0]!)
+ const sourceSideTabs = page.getByRole('tablist', { name: 'Source version to display' })
+ const removedLine = source.locator('[data-source-operation="removed"]').first()
+ const addedLine = source.locator('[data-source-operation="added"]').first()
+ await expect(removedLine).toBeVisible()
+ await expect(addedLine).toBeHidden()
+ await sourceSideTabs.getByRole('tab', { name: 'After' }).click()
+ await expect(removedLine).toBeHidden()
+ await expect(addedLine).toBeVisible()
+ })
+
+ test('AUD-24: Full document headings align in paired and single layouts', async ({ comparison, page }) => {
+ for (const width of [1100, 620]) {
+ await comparison.mount({ before: 'Old document.', after: 'New document.', width })
+ await page.getByRole('tab', { name: 'Full documents' }).click()
+ const sideTabs = page.getByRole('tablist', { name: 'Version to display' })
+ if (width === 1100) {
+ await expect(page.locator('.text-comparison__document--before')).toBeVisible()
+ await expect(page.locator('.text-comparison__document--after')).toBeVisible()
+ for (const button of await page.getByLabel('Change navigation').getByRole('button').all()) {
+ await expect(button).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
+ }
+ }
+ for (const side of ['before', 'after']) {
+ if (width === 620) {
+ await sideTabs.getByRole('tab', { name: side === 'before' ? 'Before' : 'After' }).click()
+ }
+ const article = page.locator(`.text-comparison__document--${side}`)
+ await expect(article).toBeVisible()
+ const header = page.locator(`.text-comparison__document--${side} > header`)
+ const headings = header.locator('.document-heading > span, .document-heading > h2, .document-legend')
+ await expect(headings).toHaveCount(3)
+ await expect(header.getByRole('heading', { level: 2 })).toHaveCSS(
+ 'font-size',
+ await article.evaluate((element) => getComputedStyle(element).fontSize),
+ )
+ const rectangles = await headings.evaluateAll((elements) => elements.map((element) => {
+ const rect = element.getBoundingClientRect()
+ return { width: rect.width, height: rect.height, center: rect.top + rect.height / 2 }
+ }))
+ expect(rectangles.every(({ width, height }) => width > 0 && height > 0)).toBe(true)
+ const centers = rectangles.map(({ center }) => center)
+ expect(Math.max(...centers) - Math.min(...centers)).toBeLessThan(4)
+ }
+ await comparison.destroy()
+ }
+ })
+})
+
+async function assertFormattingFilterMove(comparison: ComparisonHarness, page: Page, contents: ComparisonContents, direction: 'next' | 'previous') {
+ await comparison.mount(contents)
+ const rows = page.locator('[data-comparison-select]')
+ const formatting = rows.filter({ hasText: /Bold changed/ })
+ await expect(formatting).toHaveCount(1)
+ const ids = await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-comparison-select') ?? ''))
+ const formattingId = await formatting.getAttribute('data-comparison-select')
+ const formattingIndex = ids.indexOf(formattingId ?? '')
+ const expectedIndex = direction === 'next' ? formattingIndex + 1 : formattingIndex - 1
+ await formatting.click()
+ await page.getByRole('checkbox', { name: 'Hide formatting-only changes' }).check()
+ const current = page.locator('[data-comparison-select][aria-current="true"]')
+ await expect(current).toHaveCount(1)
+ await expect(current).toHaveAttribute('data-comparison-select', ids[expectedIndex]!)
+}
+
+async function mountMaximumSquare(comparison: ComparisonHarness) {
+ const maximumBefore = axis('maximum', MAXIMUM_SQUARE_AXIS, 'before')
+ const maximumAfter = axis('maximum', MAXIMUM_SQUARE_AXIS, 'after')
+ return comparison.mount(maximumSquareFixture(maximumBefore, maximumAfter))
+}
+
+async function attachMeasurement(testInfo: TestInfo, fixture: string, measurement: ComparisonMeasurement, work?: Record) {
+ await testInfo.attach(`${fixture}-measurement.json`, {
+ body: Buffer.from(JSON.stringify({ measurement, work }, null, 2)),
+ contentType: 'application/json',
+ })
+}
+
+async function readChromiumHeap(cdp: CDPSession) {
+ const { metrics } = await cdp.send('Performance.getMetrics')
+ const heap = metrics.find(({ name }) => name === 'JSHeapUsedSize')?.value
+ if (typeof heap !== 'number' || !Number.isFinite(heap)) {
+ throw new Error('AUD-14 requires Chromium CDP Performance.JSHeapUsedSize memory evidence')
+ }
+ return heap
+}
+
+function nearLineFloorFixture(): ComparisonContents {
+ const before = Array.from({ length: 6490 }, (_, index) => `# fixed floor ${index}`).join('\n')
+ const after = before.replace('# fixed floor 3245', '# changed floor 3245')
+ return { before, after }
+}
+
+function maximumSquareFixture(maximumBefore: readonly string[], maximumAfter: readonly string[]): ComparisonContents {
+ const before = ['# exact start', ...maximumBefore, '# exact end'].join('\n\n')
+ const after = ['# exact start', ...maximumAfter, '# exact end'].join('\n\n')
+ return { before, after }
+}
+
+function tableLedgerFixture(): ComparisonContents {
+ return {
+ before: `${ledgerTable(200, 12, 'a')}\n\n# exact table separator\n\n${ledgerTable(10, 12, 'c')}`,
+ after: `${ledgerTable(200, 12, 'b')}\n\n# exact table separator\n\n${ledgerTable(10, 12, 'd')}`,
+ }
+}
+
+function ledgerTable(columns: number, textLength: number, suffix: string) {
+ const cell = (column: number) => {
+ const prefix = `000-${column.toString().padStart(3, '0')}-`
+ return `${prefix}${suffix.repeat(textLength - prefix.length)}`
+ }
+ const header = Array.from({ length: columns }, (_value, column) => ` ${cell(column)} `).join('|')
+ const divider = Array.from({ length: columns }, () => ' --- ').join('|')
+ return `|${header}|\n|${divider}|`
+}
+
+function axis(prefix: string, count: number, suffix: string) {
+ const axisId = prefix.match(/\d+/)?.[0] ?? prefix[0]
+ return Array.from({ length: count }, (_, index) => `${axisId}:${index.toString(36)}:${suffix[0]}`)
+}
diff --git a/playwright/comparison/fixtures.ts b/playwright/comparison/fixtures.ts
new file mode 100644
index 00000000000..17961fc0ecb
--- /dev/null
+++ b/playwright/comparison/fixtures.ts
@@ -0,0 +1,26 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { test as base } from '@playwright/test'
+import { ComparisonHarness } from './support/comparisonHarness.ts'
+
+interface ComparisonFixtures {
+ comparison: ComparisonHarness
+}
+
+export const test = base.extend({
+ comparison: async ({ page }, use, testInfo) => {
+ const comparison = new ComparisonHarness(page)
+ try {
+ await comparison.open()
+ await use(comparison)
+ } finally {
+ await comparison.attachEvidence(testInfo)
+ comparison.assertNoUnexpectedFailures()
+ }
+ },
+})
+
+export { expect } from '@playwright/test'
diff --git a/playwright/comparison/support/comparisonHarness.ts b/playwright/comparison/support/comparisonHarness.ts
new file mode 100644
index 00000000000..201a0a7b75e
--- /dev/null
+++ b/playwright/comparison/support/comparisonHarness.ts
@@ -0,0 +1,335 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ConsoleMessage, Page, Request, Response, TestInfo } from '@playwright/test'
+
+import AxeBuilder from '@axe-core/playwright'
+import { expect } from '@playwright/test'
+
+const HARNESS_PATH = '/index.php/login'
+
+export interface ComparisonContents {
+ before: string
+ after: string
+ fileId?: number
+ rejectLoaded?: boolean
+ width?: number
+ height?: number
+}
+
+export interface ComparisonMeasurement {
+ durationMilliseconds: number
+ loadedCallbackCalls: number
+ rootCount: number
+ proseMirrorCount: number
+}
+
+interface RuntimeFailure {
+ type: 'console' | 'pageerror' | 'requestfailed' | 'response'
+ message: string
+ url?: string
+ status?: number
+}
+
+interface ObserverCounts {
+ resize: number
+ mutation: number
+}
+
+export class ComparisonHarness {
+ readonly page: Page
+ readonly failures: RuntimeFailure[] = []
+ readonly consoleMessages: Array<{ type: string, text: string }> = []
+ readonly network: Array<{ method: string, status?: number, url: string, failure?: string }> = []
+ #allowedFailures: RegExp[] = []
+
+ constructor(page: Page) {
+ this.page = page
+ page.on('console', (message) => this.#captureConsole(message))
+ page.on('pageerror', (error) => this.failures.push({ type: 'pageerror', message: error.message }))
+ page.on('requestfailed', (request) => this.#captureFailedRequest(request))
+ page.on('response', (response) => this.#captureResponse(response))
+ }
+
+ async open() {
+ await this.page.route(`**${HARNESS_PATH}`, async (route) => {
+ const response = await route.fetch()
+ const headers = response.headers()
+ const policy = headers['content-security-policy'] ?? ''
+ if (policy && !policy.includes('worker-src')) {
+ headers['content-security-policy'] = `${policy}; worker-src 'self'`
+ }
+ await route.fulfill({ response, headers })
+ })
+ await this.page.goto(HARNESS_PATH, { waitUntil: 'domcontentloaded' })
+ await this.page.waitForFunction(() => Boolean(window.OC?.filePath && window.OCA))
+ const textRoot = (await this.page.evaluate(() => (
+ window as typeof window & { OC: { appswebroots: Record } }
+ ).OC.appswebroots.text)).replace(/\/$/, '')
+ const editorBundle = `${textRoot}/js/text-editor.mjs`
+ await this.page.evaluate(async ({ bundle, root }) => {
+ const entryUrl = new URL(`${root}/css/text-editor.css`, location.href)
+ const entryResponse = await fetch(entryUrl)
+ if (!entryResponse.ok) {
+ throw new Error('Could not load the Text editor stylesheet entry')
+ }
+ const imports = [...(await entryResponse.text()).matchAll(/@import\s+['"]([^'"]+)['"]/g)]
+ await Promise.all(imports.map(([, path]) => new Promise((resolve, reject) => {
+ const stylesheet = document.createElement('link')
+ stylesheet.rel = 'stylesheet'
+ stylesheet.href = new URL(path, entryUrl).href
+ stylesheet.addEventListener('load', () => resolve(), { once: true })
+ stylesheet.addEventListener('error', () => reject(new Error('Could not load a Text editor stylesheet chunk')), { once: true })
+ document.head.append(stylesheet)
+ })))
+ await import(bundle)
+ }, { bundle: editorBundle, root: textRoot })
+ await expect.poll(() => this.page.evaluate(() => typeof window.OCA?.Text?.createMarkdownContentComparison), {
+ message: `The mounted production bundle ${editorBundle} must expose the public comparison factory`,
+ }).toBe('function')
+ await this.page.evaluate((root) => {
+ const appRoot = new URL(`${root}/`, location.href).href
+ document.querySelectorAll('link[rel="stylesheet"]')
+ .forEach((link) => !link.href.startsWith(appRoot) && link.remove())
+ const style = document.createElement('style')
+ style.textContent = `
+ html, body {
+ --color-element-info: #007aa3;
+ --color-error: #f0b5b5;
+ --color-error-hover: #fbeaea;
+ --color-main-background: #fff;
+ --color-main-text: #222;
+ --color-primary-element: #00679e;
+ --color-primary-element-light: #e5f2f8;
+ --color-success: #b5dfb8;
+ --color-success-hover: #eaf5eb;
+ --color-text-maxcontrast: #4a4a4a;
+ --color-warning: #8a6116;
+ block-size: 100%;
+ margin: 0;
+ }
+ body { background: var(--color-main-background); color: var(--color-main-text); }
+ #text-comparison-harness {
+ box-sizing: border-box;
+ color: var(--color-main-text);
+ margin: 0 auto;
+ overflow: hidden;
+ }
+ `
+ const host = document.createElement('main')
+ host.id = 'text-comparison-harness'
+ document.head.append(style)
+ document.body.removeAttribute('id')
+ document.body.removeAttribute('class')
+ document.body.replaceChildren(host)
+
+ const state = {
+ instances: [] as Array<{ destroy: () => void }>,
+ resizeObservers: new Set(),
+ mutationObservers: new Set(),
+ }
+ Object.assign(window, { __textComparisonAcceptance: state })
+
+ if (typeof ResizeObserver !== 'undefined') {
+ const originalObserve = ResizeObserver.prototype.observe
+ const originalDisconnect = ResizeObserver.prototype.disconnect
+ ResizeObserver.prototype.observe = function(target, options) {
+ state.resizeObservers.add(this)
+ return originalObserve.call(this, target, options)
+ }
+ ResizeObserver.prototype.disconnect = function() {
+ state.resizeObservers.delete(this)
+ return originalDisconnect.call(this)
+ }
+ }
+ if (typeof MutationObserver !== 'undefined') {
+ const originalObserve = MutationObserver.prototype.observe
+ const originalDisconnect = MutationObserver.prototype.disconnect
+ MutationObserver.prototype.observe = function(target, options) {
+ state.mutationObservers.add(this)
+ return originalObserve.call(this, target, options)
+ }
+ MutationObserver.prototype.disconnect = function() {
+ state.mutationObservers.delete(this)
+ return originalDisconnect.call(this)
+ }
+ }
+ }, textRoot)
+ await this.page.waitForLoadState('networkidle')
+ await this.page.waitForTimeout(500)
+ this.resetCapture()
+ }
+
+ resetCapture() {
+ this.failures.splice(0)
+ this.consoleMessages.splice(0)
+ this.network.splice(0)
+ this.#allowedFailures = []
+ }
+
+ allowFailure(pattern: RegExp) {
+ this.#allowedFailures.push(pattern)
+ }
+
+ async mount(contents: ComparisonContents): Promise {
+ if (contents.rejectLoaded) {
+ this.allowFailure(/acceptance forced loaded callback failure/)
+ }
+ return this.page.evaluate(async ({ before, after, fileId, rejectLoaded = false, width = 1100, height = 760 }) => {
+ const state = window.__textComparisonAcceptance
+ const host = document.querySelector('#text-comparison-harness')!
+ host.style.inlineSize = `${width}px`
+ host.style.blockSize = `${height}px`
+ const started = performance.now()
+ let loadedCallbackCalls = 0
+ const instance = await window.OCA.Text.createMarkdownContentComparison({
+ afterContent: after,
+ beforeContent: before,
+ el: host,
+ fileId,
+ noLazyImages: true,
+ onLoaded: rejectLoaded
+ ? async () => {
+ loadedCallbackCalls++
+ throw new Error('acceptance forced loaded callback failure')
+ }
+ : undefined,
+ })
+ const durationMilliseconds = performance.now() - started
+ state.instances.push(instance)
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
+ return {
+ durationMilliseconds,
+ loadedCallbackCalls,
+ rootCount: host.querySelectorAll('.text-comparison-root').length,
+ proseMirrorCount: host.querySelectorAll('.ProseMirror').length,
+ }
+ }, contents)
+ }
+
+ async destroy(times = 2) {
+ await this.page.evaluate((count) => {
+ const state = window.__textComparisonAcceptance
+ for (const instance of state.instances.splice(0)) {
+ for (let index = 0; index < count; index++) {
+ instance.destroy()
+ }
+ }
+ }, times)
+ }
+
+ async observerCounts(): Promise {
+ return this.page.evaluate(() => ({
+ mutation: window.__textComparisonAcceptance.mutationObservers.size,
+ resize: window.__textComparisonAcceptance.resizeObservers.size,
+ }))
+ }
+
+ async forceEditorInitializationFailure() {
+ await this.page.evaluate(() => {
+ const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')!
+ Object.defineProperty(Element.prototype, 'innerHTML', {
+ ...descriptor,
+ set(value: string) {
+ void value
+ Object.defineProperty(Element.prototype, 'innerHTML', descriptor)
+ throw new Error('acceptance forced editor initialization failure')
+ },
+ })
+ })
+ this.allowFailure(/acceptance forced editor initialization failure/)
+ }
+
+ async forceProjectionFailure() {
+ await this.page.evaluate(() => {
+ const original = Element.prototype.setAttribute
+ Element.prototype.setAttribute = function(name, value) {
+ if (name === 'data-comparison-change') {
+ Element.prototype.setAttribute = original
+ throw new Error('acceptance forced projection failure')
+ }
+ return original.call(this, name, value)
+ }
+ })
+ this.allowFailure(/acceptance forced projection failure/)
+ }
+
+ async assertAccessibleComparison() {
+ const violations = await this.page.locator('#text-comparison-harness').evaluate((root) => {
+ const visible = (element: HTMLElement) => Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
+ const violations: string[] = []
+ const ids = [...root.querySelectorAll('[id]')].map(({ id }) => id)
+ for (const id of new Set(ids)) {
+ if (ids.filter((candidate) => candidate === id).length > 1) {
+ violations.push(`duplicate id: ${id}`)
+ }
+ }
+ for (const button of root.querySelectorAll('button')) {
+ if (visible(button) && !(button.getAttribute('aria-label') || button.textContent?.trim() || button.title)) {
+ violations.push('visible button has no accessible name')
+ }
+ }
+ for (const tablist of root.querySelectorAll('[role="tablist"]')) {
+ const selected = [...tablist.querySelectorAll('[role="tab"]')].filter((tab) => tab.getAttribute('aria-selected') === 'true')
+ if (visible(tablist) && selected.length !== 1) {
+ violations.push('visible tablist must have exactly one selected tab')
+ }
+ }
+ const liveRegion = root.querySelector('[aria-live="polite"][aria-atomic="true"]')
+ if (!liveRegion) {
+ violations.push('comparison has no polite atomic live region')
+ }
+ return violations
+ })
+ expect(violations).toEqual([])
+ const axe = await new AxeBuilder({ page: this.page })
+ .include('#text-comparison-harness')
+ .analyze()
+ expect(axe.violations, 'axe accessibility violations').toEqual([])
+ }
+
+ async attachEvidence(testInfo: TestInfo) {
+ await testInfo.attach('comparison-console-network.json', {
+ body: Buffer.from(JSON.stringify({ console: this.consoleMessages, failures: this.failures, network: this.network }, null, 2)),
+ contentType: 'application/json',
+ })
+ }
+
+ assertNoUnexpectedFailures() {
+ const unexpected = this.failures.filter(({ message }) => !this.#allowedFailures.some((pattern) => pattern.test(message)))
+ expect(unexpected, 'unexpected browser console, page, or network failures').toEqual([])
+ }
+
+ #captureConsole(message: ConsoleMessage) {
+ this.consoleMessages.push({ type: message.type(), text: message.text() })
+ if (message.type() === 'error') {
+ this.failures.push({ type: 'console', message: message.text() })
+ }
+ }
+
+ #captureFailedRequest(request: Request) {
+ const failure = request.failure()?.errorText ?? 'request failed'
+ this.network.push({ method: request.method(), url: request.url(), failure })
+ this.failures.push({ type: 'requestfailed', message: failure, url: request.url() })
+ }
+
+ #captureResponse(response: Response) {
+ this.network.push({ method: response.request().method(), status: response.status(), url: response.url() })
+ if (response.status() >= 400) {
+ this.failures.push({ type: 'response', message: `HTTP ${response.status()}`, status: response.status(), url: response.url() })
+ }
+ }
+}
+
+declare global {
+ interface Window {
+ OC?: { filePath?: (...parts: string[]) => string }
+ __textComparisonAcceptance: {
+ instances: Array<{ destroy: () => void }>
+ resizeObservers: Set
+ mutationObservers: Set
+ }
+ }
+}
diff --git a/src/comparison/markdownSourceComparison.ts b/src/comparison/markdownSourceComparison.ts
new file mode 100644
index 00000000000..78e52f47668
--- /dev/null
+++ b/src/comparison/markdownSourceComparison.ts
@@ -0,0 +1,526 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Change } from 'diff'
+import type { SourceComparisonWorkerRequest as WorkerRequest, SourceComparisonWorkerResponse as WorkerResponse } from './markdownSourceComparisonProtocol.ts'
+
+import { diffWordsWithSpace } from 'diff'
+import { compareMarkdownSourceLines } from './markdownSourceComparisonProtocol.ts'
+import { displayBoundedMarkdownSource } from './markdownSourceDisplay.ts'
+
+export type SourceEol = 'lf' | 'crlf' | 'cr' | 'none'
+export type SourceLineEnding = Exclude | 'mixed'
+
+export interface SourceDiffSegment {
+ text: string
+ changed: boolean
+}
+export interface SourceDiffLine {
+ number: number
+ text: string
+ eol: SourceEol
+ changed: boolean
+ segments: readonly SourceDiffSegment[]
+}
+export interface SourceDiffRow {
+ before?: SourceDiffLine
+ after?: SourceDiffLine
+}
+export interface SourceDiffHunk {
+ id: string
+ beforeStart: number
+ afterStart: number
+ rows: readonly SourceDiffRow[]
+}
+export interface SourceDiffGap {
+ id: string
+ slot: number
+ beforeFrom: number
+ beforeTo: number
+ afterFrom: number
+ afterTo: number
+ count: number
+}
+export interface SourceLineEndingChange {
+ before: SourceLineEnding
+ after: SourceLineEnding
+}
+export interface SourceDiffReadyModel {
+ status: 'ready'
+ hunks: readonly SourceDiffHunk[]
+ gaps: readonly SourceDiffGap[]
+ lineEndingChange: SourceLineEndingChange | null
+}
+export interface SourceDiffLimitedModel {
+ status: 'limited'
+ reason: 'size' | 'complexity'
+}
+export type SourceDiffModel = SourceDiffReadyModel | SourceDiffLimitedModel
+export type SourceGapMaterializer = typeof materializeSourceDiffGap
+
+type SourceLine = SourceDiffLine
+interface ChangedRun {
+ beforeFrom: number
+ beforeTo: number
+ afterFrom: number
+ afterTo: number
+}
+interface HunkRange extends ChangedRun {
+ beforeContextFrom: number
+ beforeContextTo: number
+ afterContextFrom: number
+ afterContextTo: number
+}
+
+export const SOURCE_DIFF_LIMITS = Object.freeze({
+ maximumCharacters: 2_000_000,
+ maximumLines: 60_000,
+ maximumEditLength: 50_000,
+ timeoutMilliseconds: 2_000,
+ contextLines: 3,
+ maximumDisplayedRows: 5_000,
+ maximumGapPageRows: 500,
+ maximumWordDiffLines: 20,
+ maximumWordDiffCharacters: 2_000,
+ maximumWordDiffMilliseconds: 100,
+ maximumWordDiffPairMilliseconds: 10,
+})
+const LIMITS = SOURCE_DIFF_LIMITS
+
+export async function createMarkdownSourceComparison(before: string, after: string, signal?: AbortSignal): Promise {
+ if (
+ before.length + after.length > LIMITS.maximumCharacters
+ || sourceLineCount(before) + sourceLineCount(after)
+ > LIMITS.maximumLines
+ || displayBoundedMarkdownSource(before).truncated
+ || displayBoundedMarkdownSource(after).truncated
+ ) {
+ return { status: 'limited', reason: 'size' }
+ }
+ checkAbort(signal)
+ const normalizedBefore = normalize(before)
+ const normalizedAfter = normalize(after)
+ const changes = await computeLineChanges(
+ normalizedBefore,
+ normalizedAfter,
+ signal,
+ )
+ if (!changes) {
+ return { status: 'limited', reason: 'complexity' }
+ }
+ checkAbort(signal)
+ return buildSourceModel(
+ before,
+ after,
+ normalizedBefore === normalizedAfter,
+ changes,
+ )
+}
+
+export function materializeSourceDiffGap(before: string, after: string, gap: SourceDiffGap, maximumRows: number = LIMITS.maximumGapPageRows, offset: number = 0) {
+ const finiteMaximum = Number.isFinite(maximumRows)
+ ? Math.trunc(maximumRows)
+ : LIMITS.maximumGapPageRows
+ const rowLimit = Math.max(
+ 0,
+ Math.min(finiteMaximum, LIMITS.maximumGapPageRows),
+ )
+ const finiteOffset = Number.isFinite(offset) ? Math.trunc(offset) : 0
+ const rowOffset = Math.max(0, Math.min(finiteOffset, gap.count))
+ const beforeFrom = Math.min(gap.beforeFrom + rowOffset, gap.beforeTo)
+ const afterFrom = Math.min(gap.afterFrom + rowOffset, gap.afterTo)
+ const beforeGap = splitRange(
+ before,
+ beforeFrom,
+ Math.min(beforeFrom + rowLimit, gap.beforeTo),
+ rowLimit,
+ )
+ const afterGap = splitRange(
+ after,
+ afterFrom,
+ Math.min(afterFrom + rowLimit, gap.afterTo),
+ rowLimit,
+ )
+ return pairSourceRows(beforeGap, afterGap)
+}
+
+async function computeLineChanges(before: string, after: string, signal?: AbortSignal) {
+ const request: WorkerRequest = {
+ before,
+ after,
+ maximumEditLength: LIMITS.maximumEditLength,
+ timeoutMilliseconds: LIMITS.timeoutMilliseconds,
+ }
+ if (typeof Worker === 'undefined') {
+ const response = compareMarkdownSourceLines(request)
+ return response.status === 'ready' ? response.changes : undefined
+ }
+ const worker = new Worker(
+ new URL('./markdownSourceComparison.worker.ts', import.meta.url),
+ { type: 'module' },
+ )
+ return new Promise((resolve, reject) => {
+ let settled = false
+ const settle = (action: () => void) => {
+ if (settled) {
+ return
+ }
+ settled = true
+ signal?.removeEventListener('abort', abort)
+ try {
+ worker.terminate()
+ } finally {
+ action()
+ }
+ }
+ const fail = (error: unknown) => settle(() => reject(error))
+ function abort() {
+ fail(abortError())
+ }
+ const workerError = () => fail(new Error('Source comparison worker failed'))
+ worker.onmessage = ({ data }: MessageEvent) => {
+ if (!isSourceComparisonWorkerResponse(data)) {
+ workerError()
+ return
+ }
+ settle(() => resolve(data.status === 'ready' ? data.changes : undefined))
+ }
+ worker.onerror = workerError
+ worker.onmessageerror = workerError
+ signal?.addEventListener('abort', abort, { once: true })
+ if (signal?.aborted) {
+ abort()
+ return
+ }
+ try {
+ worker.postMessage(request)
+ } catch (error) {
+ fail(error)
+ }
+ })
+}
+
+function isSourceComparisonWorkerResponse(response: unknown): response is WorkerResponse {
+ if (!response || typeof response !== 'object') {
+ return false
+ }
+ const candidate = response as { status?: unknown, changes?: unknown }
+ return (
+ candidate.status === 'limited'
+ || (candidate.status === 'ready' && Array.isArray(candidate.changes))
+ )
+}
+
+function buildSourceModel(before: string, after: string, normalizedEqual: boolean, changes: Change[]): SourceDiffModel {
+ const beforeLines = splitRange(before)
+ const afterLines = splitRange(after)
+ const ranges = mergeRanges(changedRuns(changes).map((run) => withContext(run, beforeLines.length, afterLines.length)))
+ const displayedRows = ranges.reduce(
+ (total, range) => total
+ + Math.max(0, range.beforeContextTo - range.beforeContextFrom)
+ + Math.max(0, range.afterContextTo - range.afterContextFrom),
+ 0,
+ )
+ if (displayedRows > LIMITS.maximumDisplayedRows) {
+ return { status: 'limited', reason: 'complexity' }
+ }
+ const hunks = ranges.map((range, index) => createHunk(range, beforeLines, afterLines, index))
+ return {
+ status: 'ready',
+ hunks,
+ gaps: createGaps(ranges, beforeLines, afterLines),
+ lineEndingChange: summarizeLineEndingChange(
+ beforeLines,
+ afterLines,
+ normalizedEqual,
+ ),
+ }
+}
+
+function changedRuns(changes: Change[]) {
+ const runs: ChangedRun[] = []
+ let beforeLine = 0
+ let afterLine = 0
+ let current: ChangedRun | null = null
+ for (const change of changes) {
+ const count = change.count ?? sourceLineCount(change.value)
+ if (!change.added && !change.removed) {
+ if (current) {
+ runs.push(current)
+ }
+ current = null
+ beforeLine += count
+ afterLine += count
+ continue
+ }
+ current ??= {
+ beforeFrom: beforeLine,
+ beforeTo: beforeLine,
+ afterFrom: afterLine,
+ afterTo: afterLine,
+ }
+ if (change.removed) {
+ current.beforeTo += count
+ beforeLine += count
+ } else {
+ current.afterTo += count
+ afterLine += count
+ }
+ }
+ if (current) {
+ runs.push(current)
+ }
+ return runs
+}
+
+function withContext(run: ChangedRun, beforeLength: number, afterLength: number): HunkRange {
+ const context = (from: number, to: number, length: number) => ({
+ from: Math.max(0, from - LIMITS.contextLines),
+ to: Math.min(length, Math.max(to, from + 1) + LIMITS.contextLines),
+ })
+ const before = context(run.beforeFrom, run.beforeTo, beforeLength)
+ const after = context(run.afterFrom, run.afterTo, afterLength)
+ return {
+ ...run,
+ beforeContextFrom: before.from,
+ beforeContextTo: before.to,
+ afterContextFrom: after.from,
+ afterContextTo: after.to,
+ }
+}
+
+function mergeRanges(ranges: HunkRange[]) {
+ const merged: HunkRange[] = []
+ for (const range of ranges) {
+ const previous = merged.at(-1)
+ if (
+ previous
+ && range.beforeContextFrom <= previous.beforeContextTo
+ && range.afterContextFrom <= previous.afterContextTo
+ ) {
+ previous.beforeTo = Math.max(previous.beforeTo, range.beforeTo)
+ previous.afterTo = Math.max(previous.afterTo, range.afterTo)
+ previous.beforeContextTo = Math.max(
+ previous.beforeContextTo,
+ range.beforeContextTo,
+ )
+ previous.afterContextTo = Math.max(
+ previous.afterContextTo,
+ range.afterContextTo,
+ )
+ } else {
+ merged.push({ ...range })
+ }
+ }
+ return merged
+}
+
+function createHunk(range: HunkRange, before: SourceLine[], after: SourceLine[], index: number): SourceDiffHunk {
+ const select = (lines: SourceLine[], contextFrom: number, contextTo: number, from: number, to: number) => lines
+ .slice(contextFrom, contextTo)
+ .map((line, offset) => cloneLine(line, contextFrom + offset >= from && contextFrom + offset < to))
+ const beforeHunk = select(before, range.beforeContextFrom, range.beforeContextTo, range.beforeFrom, range.beforeTo)
+ const afterHunk = select(after, range.afterContextFrom, range.afterContextTo, range.afterFrom, range.afterTo)
+ addWordEmphasis(
+ beforeHunk.filter(({ changed }) => changed),
+ afterHunk.filter(({ changed }) => changed),
+ )
+ return {
+ id: `source-hunk-${index.toString(36)}`,
+ beforeStart: beforeHunk[0]?.number ?? 0,
+ afterStart: afterHunk[0]?.number ?? 0,
+ rows: pairSourceRows(beforeHunk, afterHunk),
+ }
+}
+
+function pairSourceRows(before: readonly SourceLine[], after: readonly SourceLine[]) {
+ const rows: SourceDiffRow[] = []
+ let left = 0
+ let right = 0
+ while (left < before.length || right < after.length) {
+ if (before[left]?.changed || after[right]?.changed) {
+ const removed: SourceLine[] = []
+ const added: SourceLine[] = []
+ while (before[left]?.changed) {
+ removed.push(before[left++]!)
+ }
+ while (after[right]?.changed) {
+ added.push(after[right++]!)
+ }
+ for (
+ let index = 0;
+ index < Math.max(removed.length, added.length);
+ index++
+ ) {
+ rows.push({ before: removed[index], after: added[index] })
+ }
+ } else {
+ rows.push({ before: before[left++], after: after[right++] })
+ }
+ }
+ return rows
+}
+
+function addWordEmphasis(before: SourceLine[], after: SourceLine[]) {
+ const count = Math.min(
+ before.length,
+ after.length,
+ LIMITS.maximumWordDiffLines,
+ )
+ const deadline = Date.now() + LIMITS.maximumWordDiffMilliseconds
+ for (
+ let index = 0;
+ index < count;
+ index++
+ ) {
+ const left = before[index]!
+ const right = after[index]!
+ if (
+ left.text.length + right.text.length
+ > LIMITS.maximumWordDiffCharacters
+ || Date.now() > deadline
+ ) {
+ continue
+ }
+ const pairDeadline
+ = Date.now() + LIMITS.maximumWordDiffPairMilliseconds
+ const words = diffWordsWithSpace(left.text, right.text)
+ if (Date.now() > pairDeadline) {
+ continue
+ }
+ left.segments = words
+ .filter(({ added }) => !added)
+ .map(({ value, removed }) => ({ text: value, changed: Boolean(removed) }))
+ right.segments = words
+ .filter(({ removed }) => !removed)
+ .map(({ value, added }) => ({ text: value, changed: Boolean(added) }))
+ }
+}
+
+function createGaps(ranges: HunkRange[], before: SourceLine[], after: SourceLine[]) {
+ const gaps: SourceDiffGap[] = []
+ for (let slot = 0; slot <= ranges.length; slot++) {
+ const beforeFrom = slot === 0 ? 0 : ranges[slot - 1]!.beforeContextTo
+ const beforeTo
+ = slot === ranges.length ? before.length : ranges[slot]!.beforeContextFrom
+ const afterFrom = slot === 0 ? 0 : ranges[slot - 1]!.afterContextTo
+ const afterTo
+ = slot === ranges.length ? after.length : ranges[slot]!.afterContextFrom
+ const beforeCount = beforeTo - beforeFrom
+ const afterCount = afterTo - afterFrom
+ if (beforeCount || afterCount) {
+ gaps.push({
+ id: `source-gap-${slot.toString(36)}`,
+ slot,
+ beforeFrom,
+ beforeTo,
+ afterFrom,
+ afterTo,
+ count: Math.max(beforeCount, afterCount),
+ })
+ }
+ }
+ return gaps
+}
+
+function splitRange(source: string, from = 0, to = Number.POSITIVE_INFINITY, maximumLines = Number.POSITIVE_INFINITY) {
+ if (!source || from >= to || maximumLines <= 0) {
+ return []
+ }
+ const lines: SourceLine[] = []
+ const pattern = /([^\r\n]*)(\r\n|\n|\r|$)/gu
+ let lineIndex = 0
+ let match: RegExpExecArray | null
+ while ((match = pattern.exec(source))) {
+ if (!match[1] && !match[2] && match.index === source.length) {
+ break
+ }
+ if (lineIndex >= to || lines.length >= maximumLines) {
+ break
+ }
+ if (lineIndex >= from) {
+ lines.push(createSourceLine(match[1]!, match[2]!, lineIndex + 1))
+ }
+ lineIndex++
+ if (!match[2]) {
+ break
+ }
+ }
+ return lines
+}
+
+function createSourceLine(text: string, rawEol: string, number: number): SourceLine {
+ const eol: SourceEol
+ = rawEol === '\r\n'
+ ? 'crlf'
+ : rawEol === '\n'
+ ? 'lf'
+ : rawEol === '\r'
+ ? 'cr'
+ : 'none'
+ return {
+ number,
+ text,
+ eol,
+ changed: false,
+ segments: [{ text, changed: false }],
+ }
+}
+function cloneLine(line: SourceLine, changed: boolean): SourceLine {
+ return { ...line, changed, segments: [{ text: line.text, changed: false }] }
+}
+
+function summarizeLineEndingChange(before: SourceLine[], after: SourceLine[], normalizedEqual: boolean): SourceLineEndingChange | null {
+ if (
+ before.length === after.length
+ && before.every(({ eol }, index) => eol === after[index]!.eol)
+ ) {
+ return null
+ }
+ const left = lineEndingConvention(before)
+ const right = lineEndingConvention(after)
+ return left && right && (normalizedEqual || left !== right)
+ ? { before: left, after: right }
+ : null
+}
+
+function lineEndingConvention(lines: SourceLine[]): SourceLineEnding | null {
+ const endings = new Set(lines
+ .map(({ eol }) => eol)
+ .filter((eol): eol is Exclude => eol !== 'none'))
+ return endings.size === 0
+ ? null
+ : endings.size === 1
+ ? [...endings][0]!
+ : 'mixed'
+}
+function normalize(source: string) {
+ return source.replace(/\r\n?|\n/gu, '\n')
+}
+
+function sourceLineCount(source: string) {
+ if (!source) {
+ return 0
+ }
+ let count = 1
+ for (let index = 0; index < source.length; index++) {
+ if (
+ source[index] === '\n'
+ || (source[index] === '\r' && source[index + 1] !== '\n')
+ ) {
+ count++
+ }
+ }
+ return count
+}
+function abortError() {
+ return new DOMException('Source comparison aborted', 'AbortError')
+}
+
+function checkAbort(signal?: AbortSignal) {
+ if (signal?.aborted) {
+ throw abortError()
+ }
+}
diff --git a/src/comparison/markdownSourceComparison.worker.ts b/src/comparison/markdownSourceComparison.worker.ts
new file mode 100644
index 00000000000..f4994c4966f
--- /dev/null
+++ b/src/comparison/markdownSourceComparison.worker.ts
@@ -0,0 +1,12 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { SourceComparisonWorkerRequest } from './markdownSourceComparisonProtocol.ts'
+
+import { compareMarkdownSourceLines } from './markdownSourceComparisonProtocol.ts'
+
+addEventListener('message', ({ data }: MessageEvent) => {
+ postMessage(compareMarkdownSourceLines(data))
+})
diff --git a/src/comparison/markdownSourceComparisonProtocol.ts b/src/comparison/markdownSourceComparisonProtocol.ts
new file mode 100644
index 00000000000..a6e4883993b
--- /dev/null
+++ b/src/comparison/markdownSourceComparisonProtocol.ts
@@ -0,0 +1,26 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Change } from 'diff'
+
+import { diffLines } from 'diff'
+
+export interface SourceComparisonWorkerRequest {
+ before: string
+ after: string
+ maximumEditLength: number
+ timeoutMilliseconds: number
+}
+
+export type SourceComparisonWorkerResponse = { status: 'ready', changes: Change[] } | { status: 'limited' }
+
+export function compareMarkdownSourceLines(request: SourceComparisonWorkerRequest): SourceComparisonWorkerResponse {
+ const changes = diffLines(request.before, request.after, {
+ stripTrailingCr: false,
+ maxEditLength: request.maximumEditLength,
+ timeout: request.timeoutMilliseconds,
+ })
+ return changes ? { status: 'ready', changes } : { status: 'limited' }
+}
diff --git a/src/components/MarkdownContentComparison.vue b/src/components/MarkdownContentComparison.vue
index 1a26137c968..a7ed89846fa 100644
--- a/src/components/MarkdownContentComparison.vue
+++ b/src/components/MarkdownContentComparison.vue
@@ -104,6 +104,13 @@
t('text', 'No rendered differences — Markdown syntax differs.')
}}
+
+ {{ t('text', 'Open Markdown source') }}
+
{{ t('text', 'No differences.') }}
@@ -157,17 +164,35 @@
+
+
+
+ {{
+ t(
+ 'text',
+ 'Source compares literal Markdown, so its change groups can differ from rendered changes.',
+ )
+ }}
+
+
+
+
+
+
diff --git a/src/tests/comparison/MarkdownSourceComponent.spec.ts b/src/tests/comparison/MarkdownSourceComponent.spec.ts
index daecc93ceaf..f095354e1e1 100644
--- a/src/tests/comparison/MarkdownSourceComponent.spec.ts
+++ b/src/tests/comparison/MarkdownSourceComponent.spec.ts
@@ -3,7 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import MarkdownSourceComparison from '../../components/MarkdownSourceComparison.vue'
+import { SOURCE_DIFF_LIMITS } from '../../comparison/markdownSourceComparison.ts'
import {
COMPLETE_SOURCE_DISPLAY_LIMITS,
displayBoundedMarkdownSource,
@@ -64,4 +67,121 @@ describe('Markdown source component', () => {
expect(result.text.isWellFormed()).toBe(true)
expect(result.text.length).toBeLessThanOrEqual(COMPLETE_SOURCE_DISPLAY_LIMITS.maximumVisibleCharactersPerLine)
})
+
+ it('shows complete fallback immediately, then enhanced literal hunks', async () => {
+ const wrapper = mount(MarkdownSourceComparison, {
+ props: { beforeContent: 'old\t \n', afterContent: 'new\u200B\n', layoutMode: 'paired' },
+ })
+ expect(wrapper.find('[data-comparison-source-fallback]').exists()).toBe(true)
+ await vi.waitFor(() => expect(wrapper.find('[data-source-hunk]').exists()).toBe(true))
+ expect(wrapper.text()).toContain('old')
+ expect(wrapper.text()).toContain('new⟦ZWSP⟧')
+ })
+
+ it('AUD-05 exposes side, operation, whitespace, EOL, and missing-newline semantics', async () => {
+ const wrapper = mount(MarkdownSourceComparison, {
+ props: {
+ beforeContent: `old${String.fromCharCode(9)}\r\ntrail `,
+ afterContent: 'new\ntrail\n',
+ layoutMode: 'paired',
+ },
+ })
+ await vi.waitFor(() => expect(wrapper.find('[data-source-hunk]').exists()).toBe(true))
+
+ expect(wrapper.find('[data-source-side="before"]').text()).toBe('Before')
+ expect(wrapper.find('[data-source-side="after"]').text()).toBe('After')
+ expect(wrapper.find('[data-source-operation="removed"]').attributes('aria-label')).toContain('Removed line')
+ expect(wrapper.find('[data-source-operation="added"]').attributes('aria-label')).toContain('Added line')
+ expect(wrapper.find('[data-source-operation="removed"] [data-source-cue]').text()).toBe('−')
+ expect(wrapper.find('[data-source-operation="added"] [data-source-cue]').text()).toBe('+')
+ for (const token of ['TAB', 'CRLF', 'LF', '2 TRAILING SPACES', 'No newline at end of file']) {
+ expect(wrapper.text()).toContain(token)
+ }
+ })
+
+ it('omits per-line badges when paired line endings match', async () => {
+ const wrapper = mount(MarkdownSourceComparison, {
+ props: {
+ beforeContent: 'old\nunchanged\n',
+ afterContent: 'new\nunchanged\n',
+ layoutMode: 'paired',
+ },
+ })
+ await vi.waitFor(() => expect(wrapper.find('[data-source-hunk]').exists()).toBe(true))
+
+ expect(wrapper.findAll('[data-source-eol]')).toHaveLength(0)
+ })
+
+ it('AUD-13 renders the audited bidi and control set as visible inert source tokens', async () => {
+ const controls = '\u061C\u00AD\u200E\u200F\u0085\u2028\u2029'
+ const wrapper = mount(MarkdownSourceComparison, {
+ props: { beforeContent: controls, afterContent: '', layoutMode: 'paired' },
+ })
+ await vi.waitFor(() => expect(wrapper.find('[data-source-hunk]').exists()).toBe(true))
+
+ for (const token of ['ALM', 'SHY', 'LRM', 'RLM', 'NEL', 'LS', 'PS']) {
+ expect(wrapper.text()).toContain(token)
+ }
+ for (const control of controls) {
+ expect(wrapper.text()).not.toContain(control)
+ }
+ })
+
+ it('AUD-08 expands a large unchanged gap incrementally with observable source ranges', async () => {
+ const prefix = Array.from({ length: 3_000 }, (_value, index) => `prefix-${index}`)
+ const suffix = Array.from({ length: 3_000 }, (_value, index) => `suffix-${index}`)
+ const beforeContent = [...prefix, 'old literal', ...suffix].join('\n')
+ const afterContent = [...prefix, 'new literal', ...suffix].join('\n')
+ const wrapper = mount(MarkdownSourceComparison, {
+ props: { beforeContent, afterContent, layoutMode: 'paired' },
+ })
+ await vi.waitFor(() => expect(wrapper.findAll('.text-source-comparison__gap button')).toHaveLength(2))
+
+ await wrapper.findAll('[data-source-gap-toggle]')[0]!.trigger('click')
+
+ expect(wrapper.findAll('.text-source-comparison__row')).toHaveLength(SOURCE_DIFF_LIMITS.maximumGapPageRows + 7)
+ expect(wrapper.text()).toContain('prefix-0')
+ expect(wrapper.text()).toContain(`prefix-${SOURCE_DIFF_LIMITS.maximumGapPageRows - 1}`)
+ expect(wrapper.text()).not.toContain(`prefix-${SOURCE_DIFF_LIMITS.maximumGapPageRows}`)
+
+ await wrapper.find('[data-source-gap-more]').trigger('click')
+
+ expect(wrapper.findAll('.text-source-comparison__row')).toHaveLength(SOURCE_DIFF_LIMITS.maximumGapPageRows * 2 + 7)
+ expect(wrapper.text()).toContain(`prefix-${SOURCE_DIFF_LIMITS.maximumGapPageRows}`)
+ })
+
+ it('AUD-08 never expands gaps beyond the shared displayed-row ceiling', async () => {
+ const prefix = Array.from({ length: 3_000 }, (_value, index) => `prefix-${index}`)
+ const suffix = Array.from({ length: 3_000 }, (_value, index) => `suffix-${index}`)
+ const beforeContent = [...prefix, 'old literal', ...suffix].join('\n')
+ const afterContent = [...prefix, 'new literal', ...suffix].join('\n')
+ const wrapper = mount(MarkdownSourceComparison, {
+ props: { beforeContent, afterContent, layoutMode: 'paired' },
+ })
+ await vi.waitFor(() => expect(wrapper.findAll('[data-source-gap-toggle]')).toHaveLength(2))
+
+ for (const toggle of wrapper.findAll('[data-source-gap-toggle]')) {
+ await toggle.trigger('click')
+ while (wrapper.find('[data-source-gap-more]').exists()) {
+ await wrapper.find('[data-source-gap-more]').trigger('click')
+ }
+ }
+
+ expect(wrapper.findAll('.text-source-comparison__row')).toHaveLength(SOURCE_DIFF_LIMITS.maximumDisplayedRows)
+ expect(wrapper.text()).toContain('prefix-0')
+ expect(wrapper.text()).toContain('suffix-3')
+ expect(wrapper.text()).not.toContain('suffix-2999')
+ expect(wrapper.find('[data-source-gap-limited]').exists()).toBe(true)
+ }, 15_000)
+
+ it('V11 preserves bounded complete fallback when source enhancement is limited', async () => {
+ const beforeContent = 'x'.repeat(SOURCE_DIFF_LIMITS.maximumCharacters + 1)
+ const wrapper = mount(MarkdownSourceComparison, {
+ props: { beforeContent, afterContent: 'after', layoutMode: 'single' },
+ })
+ await vi.waitFor(() => expect(wrapper.find('[data-source-limited]').exists()).toBe(true))
+ expect(wrapper.find('[data-comparison-source-fallback]').exists()).toBe(true)
+ expect(wrapper.find('pre code').text()).toHaveLength(COMPLETE_SOURCE_DISPLAY_LIMITS.maximumVisibleCharactersPerLine)
+ expect(wrapper.findAll('[role="status"]').map((status) => status.text()).join(' ')).toContain('truncated')
+ })
})
diff --git a/src/tests/comparison/createMarkdownContentComparison.spec.ts b/src/tests/comparison/createMarkdownContentComparison.spec.ts
index 6f996066ae5..53c3fd57479 100644
--- a/src/tests/comparison/createMarkdownContentComparison.spec.ts
+++ b/src/tests/comparison/createMarkdownContentComparison.spec.ts
@@ -30,6 +30,26 @@ afterAll(() => {
afterEach(() => vi.restoreAllMocks())
describe('Markdown comparison factory fallback and lifecycle', () => {
+ it('V09 reports syntax-only Markdown as no semantic edit and opens Source', async () => {
+ const el = document.createElement('div')
+ const instance = await createMarkdownContentComparison({
+ beforeContent: '*same rendered text*',
+ afterContent: '_same rendered text_',
+ el,
+ })
+
+ expect(el.querySelectorAll('[data-comparison-change]')).toHaveLength(0)
+ expect(el.querySelector('[role="status"]')?.textContent).toContain('No rendered differences')
+ const openSource = el.querySelector('[data-comparison-empty-action]')
+ expect(openSource).not.toBeNull()
+ openSource!.click()
+ await nextTick()
+ expect([...el.querySelectorAll('[role="tab"]')]
+ .find(({ textContent }) => textContent?.trim() === 'Markdown source')
+ ?.getAttribute('aria-selected')).toBe('true')
+ instance.destroy()
+ })
+
it('F05 remounts complete Source when rendered editor initialization fails', async () => {
vi.spyOn(Editor.prototype, 'mount').mockImplementation(() => {
throw new Error('forced mount failure')
diff --git a/src/tests/comparison/markdownSourceComparison.spec.ts b/src/tests/comparison/markdownSourceComparison.spec.ts
new file mode 100644
index 00000000000..3af9ec62b96
--- /dev/null
+++ b/src/tests/comparison/markdownSourceComparison.spec.ts
@@ -0,0 +1,214 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { SourceDiffHunk, SourceDiffLine } from '../../comparison/markdownSourceComparison.ts'
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createMarkdownSourceComparison, materializeSourceDiffGap, SOURCE_DIFF_LIMITS } from '../../comparison/markdownSourceComparison.ts'
+import { compareMarkdownSourceLines } from '../../comparison/markdownSourceComparisonProtocol.ts'
+
+afterEach(() => vi.unstubAllGlobals())
+
+describe('literal Markdown source comparison', () => {
+ const lines = (hunks: readonly SourceDiffHunk[], side: 'before' | 'after') => hunks
+ .flatMap(({ rows }) => rows.flatMap((row) => row[side] ? [row[side]] : []))
+
+ it('V10 normalizes EOL only for matching while retaining literal line facts', async () => {
+ const model = await createMarkdownSourceComparison('tab\tvalue \r\nzero\u200Bwidth\nlast', 'tab value \nzero width\nlast\n')
+ expect(model.status).toBe('ready')
+ if (model.status !== 'ready') {
+ return
+ }
+ const before = lines(model.hunks, 'before')
+ const after = lines(model.hunks, 'after')
+ expect(before[0]).toMatchObject({ text: 'tab\tvalue ', eol: 'crlf' })
+ expect(before.some(({ text }) => text.includes('\u200b'))).toBe(true)
+ expect(before.at(-1)?.eol).toBe('none')
+ expect(after.at(-1)?.eol).toBe('lf')
+ })
+
+ it('AUD-14 materializes stable paired row records with the source model', async () => {
+ const model = await createMarkdownSourceComparison('old\nsame\n', 'new\nsame\n')
+ expect(model.status).toBe('ready')
+ if (model.status !== 'ready') {
+ return
+ }
+ const hunk = model.hunks[0]!
+
+ expect(hunk.rows.map(({ before, after }) => [before?.text, after?.text])).toEqual([['old', 'new'], ['same', 'same']])
+ expect(hunk.rows).toBe(hunk.rows)
+ })
+
+ it('AUD-08 represents large collapsed gaps without retained line clones', async () => {
+ const source = Array.from({ length: 10_000 }, (_value, index) => `line ${index}`).join('\n')
+ const model = await createMarkdownSourceComparison(source, source)
+ expect(model.status).toBe('ready')
+ if (model.status !== 'ready') {
+ return
+ }
+
+ expect(model.gaps).toEqual([{
+ id: 'source-gap-0',
+ slot: 0,
+ beforeFrom: 0,
+ beforeTo: 10_000,
+ afterFrom: 0,
+ afterTo: 10_000,
+ count: 10_000,
+ }])
+ })
+
+ it('AUD-08 materializes large gaps in bounded observable pages', async () => {
+ const source = Array.from({ length: 10_000 }, (_value, index) => `line ${index}`).join('\n')
+ const model = await createMarkdownSourceComparison(source, source)
+ expect(model.status).toBe('ready')
+ if (model.status !== 'ready') {
+ return
+ }
+ const gap = model.gaps[0]!
+
+ const firstPage = materializeSourceDiffGap(source, source, gap, Number.POSITIVE_INFINITY)
+ const secondPage = materializeSourceDiffGap(source, source, gap, SOURCE_DIFF_LIMITS.maximumGapPageRows, SOURCE_DIFF_LIMITS.maximumGapPageRows)
+
+ expect(firstPage).toHaveLength(SOURCE_DIFF_LIMITS.maximumGapPageRows)
+ expect(firstPage[0]?.before).toMatchObject({ number: 1, text: 'line 0' })
+ expect(firstPage.at(-1)?.after).toMatchObject({ number: SOURCE_DIFF_LIMITS.maximumGapPageRows, text: `line ${SOURCE_DIFF_LIMITS.maximumGapPageRows - 1}` })
+ expect(secondPage).toHaveLength(SOURCE_DIFF_LIMITS.maximumGapPageRows)
+ expect(secondPage[0]?.before).toMatchObject({ number: SOURCE_DIFF_LIMITS.maximumGapPageRows + 1, text: `line ${SOURCE_DIFF_LIMITS.maximumGapPageRows}` })
+ expect(secondPage.at(-1)?.after).toMatchObject({ number: SOURCE_DIFF_LIMITS.maximumGapPageRows * 2, text: `line ${SOURCE_DIFF_LIMITS.maximumGapPageRows * 2 - 1}` })
+ })
+
+ it('falls back when visible controls exceed the source display limit', async () => {
+ const source = `${'\t'.repeat(200_000)}😀`
+
+ await expect(createMarkdownSourceComparison(source, source)).resolves.toEqual({
+ status: 'limited',
+ reason: 'size',
+ })
+ })
+
+ it('retains complete literal lines when only line endings differ', async () => {
+ const beforeSource = 'same\r\nbytes\r\n'
+ const afterSource = 'same\nbytes\n'
+ const model = await createMarkdownSourceComparison(beforeSource, afterSource)
+ expect(model.status).toBe('ready')
+ if (model.status !== 'ready') {
+ return
+ }
+ expect(model.hunks).toEqual([])
+ expect(model.gaps).toHaveLength(1)
+ const rows = materializeSourceDiffGap(beforeSource, afterSource, model.gaps[0]!)
+ expect(rows.map(({ before }: { before?: SourceDiffLine }) => ({ text: before?.text, eol: before?.eol }))).toEqual([
+ { text: 'same', eol: 'crlf' },
+ { text: 'bytes', eol: 'crlf' },
+ ])
+ expect(rows.map(({ after }: { after?: SourceDiffLine }) => ({ text: after?.text, eol: after?.eol }))).toEqual([
+ { text: 'same', eol: 'lf' },
+ { text: 'bytes', eol: 'lf' },
+ ])
+ })
+
+ it('keeps syntax-only and HTML-like changes as literal text', async () => {
+ const model = await createMarkdownSourceComparison('# Heading\n', '# Heading #\n
')
+ expect(model.status).toBe('ready')
+ if (model.status !== 'ready') {
+ return
+ }
+ expect(lines(model.hunks, 'before').map(({ text }) => text)).toContain('')
+ expect(lines(model.hunks, 'after').map(({ text }) => text)).toContain('
')
+ })
+
+ it('reports limits and aborts without losing the caller fallback contract', async () => {
+ await expect(createMarkdownSourceComparison('x'.repeat(SOURCE_DIFF_LIMITS.maximumCharacters + 1), ''))
+ .resolves.toEqual({ status: 'limited', reason: 'size' })
+ const controller = new AbortController()
+ controller.abort()
+ await expect(createMarkdownSourceComparison('before', 'after', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+ })
+
+ it('V11 terminates an in-flight worker when the caller aborts', async () => {
+ const terminate = vi.fn()
+ vi.stubGlobal('Worker', class {
+ addEventListener() {}
+ removeEventListener() {}
+ postMessage() {}
+ terminate() { terminate() }
+ })
+ const controller = new AbortController()
+ const comparison = createMarkdownSourceComparison('before', 'after', controller.signal)
+ controller.abort()
+ await expect(comparison).rejects.toMatchObject({ name: 'AbortError' })
+ expect(terminate).toHaveBeenCalledOnce()
+ })
+
+ it('terminates the worker when posting the comparison request throws', async () => {
+ const failure = new Error('post failure')
+ const terminate = vi.fn()
+ vi.stubGlobal('Worker', class {
+ addEventListener() {}
+ removeEventListener() {}
+ postMessage() {
+ throw failure
+ }
+
+ terminate() { terminate() }
+ })
+
+ await expect(createMarkdownSourceComparison('before', 'after')).rejects.toBe(failure)
+ expect(terminate).toHaveBeenCalledOnce()
+ })
+
+ it('rejects message decoding errors once and ignores a late success', async () => {
+ const workers: Array<{ onmessage?: EventListener, onmessageerror?: EventListener }> = []
+ const terminate = vi.fn()
+ vi.stubGlobal('Worker', class {
+ onmessage?: EventListener
+ onmessageerror?: EventListener
+ constructor() {
+ workers.push(this)
+ }
+
+ postMessage() {}
+
+ terminate() { terminate() }
+ })
+ const comparison = createMarkdownSourceComparison('before', 'after')
+
+ expect(workers[0]!.onmessageerror).toBeTypeOf('function')
+ workers[0]!.onmessageerror!(new MessageEvent('messageerror'))
+ workers[0]!.onmessage!(new MessageEvent('message', { data: { status: 'ready', changes: [] } }))
+
+ await expect(comparison).rejects.toThrow('Source comparison worker failed')
+ expect(terminate).toHaveBeenCalledOnce()
+ })
+
+ it('rejects malformed worker responses after cleanup', async () => {
+ const workers: Array<{ onmessage?: EventListener }> = []
+ const terminate = vi.fn()
+ vi.stubGlobal('Worker', class {
+ onmessage?: EventListener
+ constructor() {
+ workers.push(this)
+ }
+
+ postMessage() {}
+
+ terminate() { terminate() }
+ })
+ const comparison = createMarkdownSourceComparison('before', 'after')
+
+ workers[0]!.onmessage!(new MessageEvent('message', { data: null }))
+
+ await expect(comparison).rejects.toThrow('Source comparison worker failed')
+ expect(terminate).toHaveBeenCalledOnce()
+ })
+
+ it('uses the same bounded line protocol for main-thread and worker paths', () => {
+ expect(compareMarkdownSourceLines({ before: 'old\n', after: 'new\n', maximumEditLength: 10, timeoutMilliseconds: 1000 }))
+ .toMatchObject({ status: 'ready' })
+ expect(compareMarkdownSourceLines({ before: 'old\n', after: 'new\n', maximumEditLength: 0, timeoutMilliseconds: 1000 }))
+ .toEqual({ status: 'limited' })
+ })
+})
diff --git a/src/tests/comparison/markdownSourceComparisonWorker.spec.ts b/src/tests/comparison/markdownSourceComparisonWorker.spec.ts
new file mode 100644
index 00000000000..031caf28ae2
--- /dev/null
+++ b/src/tests/comparison/markdownSourceComparisonWorker.spec.ts
@@ -0,0 +1,30 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { SourceComparisonWorkerRequest } from '../../comparison/markdownSourceComparisonProtocol.ts'
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+afterEach(() => {
+ vi.resetModules()
+ vi.unstubAllGlobals()
+})
+
+describe('Markdown source comparison worker', () => {
+ it('delegates ready and limited messages to the shared protocol', async () => {
+ let listener: ((event: MessageEvent) => void) | undefined
+ const postMessage = vi.fn()
+ vi.stubGlobal('addEventListener', vi.fn((_type, callback) => {
+ listener = callback
+ }))
+ vi.stubGlobal('postMessage', postMessage)
+ await import('../../comparison/markdownSourceComparison.worker.ts')
+ expect(listener).toBeTypeOf('function')
+ listener?.(new MessageEvent('message', { data: { before: 'old\n', after: 'new\n', maximumEditLength: 10, timeoutMilliseconds: 1000 } }))
+ expect(postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ready' }))
+ listener?.(new MessageEvent('message', { data: { before: 'old\n', after: 'new\n', maximumEditLength: 0, timeoutMilliseconds: 1000 } }))
+ expect(postMessage).toHaveBeenLastCalledWith({ status: 'limited' })
+ })
+})
diff --git a/src/tests/playwrightConfig.spec.ts b/src/tests/playwrightConfig.spec.ts
new file mode 100644
index 00000000000..3811aa2248e
--- /dev/null
+++ b/src/tests/playwrightConfig.spec.ts
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const originalComparisonBaseURL = process.env.TEXT_COMPARISON_BASE_URL
+const originalComparisonE2E = process.env.TEXT_COMPARISON_E2E
+const originalBaseURL = process.env.baseURL
+
+afterEach(() => {
+ setEnvironment('TEXT_COMPARISON_BASE_URL', originalComparisonBaseURL)
+ setEnvironment('TEXT_COMPARISON_E2E', originalComparisonE2E)
+ setEnvironment('baseURL', originalBaseURL)
+})
+
+function setEnvironment(name: string, value: string | undefined) {
+ if (value === undefined) {
+ delete process.env[name]
+ } else {
+ process.env[name] = value
+ }
+}
+
+async function loadConfig(comparisonBaseURL: string | undefined, baseURL: string | undefined, comparisonE2E?: string) {
+ setEnvironment('TEXT_COMPARISON_BASE_URL', comparisonBaseURL)
+ setEnvironment('TEXT_COMPARISON_E2E', comparisonE2E)
+ setEnvironment('baseURL', baseURL)
+ vi.resetModules()
+ return (await import('../../playwright.config.ts')).default
+}
+
+describe('Playwright comparison server selection', () => {
+ it.each([
+ { name: 'unset values', comparisonBaseURL: undefined, baseURL: undefined, expected: 'http://localhost:8089/index.php/', managed: true },
+ { name: 'empty values', comparisonBaseURL: '', baseURL: '', expected: 'http://localhost:8089/index.php/', managed: true },
+ { name: 'generic external URL', comparisonBaseURL: '', baseURL: 'https://generic.example/index.php/', expected: 'https://generic.example/index.php/', managed: false },
+ { name: 'dedicated external URL', comparisonBaseURL: 'https://text.example/index.php/', baseURL: '', expected: 'https://text.example/index.php/', managed: false },
+ { name: 'dedicated precedence', comparisonBaseURL: 'https://text.example/index.php/', baseURL: 'https://generic.example/index.php/', expected: 'https://text.example/index.php/', managed: false },
+ ])('uses the selected URL for $name', async ({ comparisonBaseURL, baseURL, expected, managed }) => {
+ const config = await loadConfig(comparisonBaseURL, baseURL)
+
+ expect(config.use?.baseURL).toBe(expected)
+ expect(config.webServer === undefined).toBe(!managed)
+ })
+
+ it('routes comparison and memory tests to their dedicated projects', async () => {
+ const config = await loadConfig(undefined, undefined, '1')
+ const projects = config.projects ?? []
+ const byName = new Map(projects.map((project) => [project.name, project]))
+ const comparisonNames = [
+ 'comparison-chromium',
+ 'comparison-webkit',
+ 'comparison-chromium-memory',
+ ]
+
+ expect(projects.map(({ name }) => name)).toEqual(['chromium', ...comparisonNames])
+ for (const name of comparisonNames) {
+ const match = byName.get(name)?.testMatch
+ expect(match).toBeInstanceOf(RegExp)
+ expect((match as RegExp).test('playwright/comparison/comparison.spec.ts')).toBe(true)
+ expect((match as RegExp).test('playwright/example.spec.ts')).toBe(false)
+ }
+ for (const name of comparisonNames.slice(0, 2)) {
+ expect(byName.get(name)?.grepInvert).toEqual(/@memory/)
+ expect(byName.get(name)?.grep).toBeUndefined()
+ }
+ expect(byName.get('comparison-chromium-memory')?.grep).toEqual(/@memory/)
+ expect(byName.get('comparison-chromium-memory')?.grepInvert).toBeUndefined()
+ const ordinaryIgnore = byName.get('chromium')?.testIgnore
+ expect(ordinaryIgnore).toBeInstanceOf(RegExp)
+ expect((ordinaryIgnore as RegExp).test('playwright/comparison/comparison.spec.ts')).toBe(true)
+ expect((ordinaryIgnore as RegExp).test('playwright/example.spec.ts')).toBe(false)
+ })
+})
diff --git a/vite.config.ts b/vite.config.ts
index 1830d335ca4..5d930dd6f08 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -50,6 +50,15 @@ const config = createAppConfig(
},
config: {
base: process.env.BASE,
+ worker: {
+ rollupOptions: {
+ output: {
+ assetFileNames: 'js/[name]-[hash][extname]',
+ chunkFileNames: 'js/[name]-[hash].worker.chunk.mjs',
+ entryFileNames: 'js/[name]-[hash].worker.mjs',
+ },
+ },
+ },
resolve: {
dedupe: ['vue'],
},