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: `![Before logo](${CORE_LOGO})\n\nOld paragraph.`, + after: `![After logo](${CORE_LOGO})\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: `![Before logo](${CORE_LOGO})`, after: `![After logo](${CORE_LOGO})` }) + 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: `![Before logo](${CORE_LOGO})`, after: `![After logo](${CORE_LOGO})` }) + 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: '![Before document](.attachments.123/document.pdf)', after: '![After document](.attachments.123/document.pdf)', 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'], },