From 4785e8ae2f3a786a7a5277620db37a90fb03d104 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 28 Sep 2026 16:26:06 +0200 Subject: [PATCH 01/14] test(comparison): drop the Playwright comparison harness The comparison Playwright suite did not exercise the app. It loaded the login page, injected the built bundle, replaced the document body and mounted the comparison factory directly, with patched observers and forced failures. It needed three extra Playwright projects, an environment switch in the config, a unit test of that config and the axe-core dependency. Remove it along with the config and workflow changes it required. The comparison gets a real end-to-end test through the Viewer version comparison in a later commit. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5.5 --- .github/workflows/playwright.yml | 8 +- package-lock.json | 24 - package.json | 1 - playwright.config.ts | 101 +-- playwright/comparison/comparison.spec.ts | 792 ------------------ playwright/comparison/fixtures.ts | 26 - .../comparison/support/comparisonHarness.ts | 335 -------- src/tests/playwrightConfig.spec.ts | 76 -- 8 files changed, 26 insertions(+), 1337 deletions(-) delete mode 100644 playwright/comparison/comparison.spec.ts delete mode 100644 playwright/comparison/fixtures.ts delete mode 100644 playwright/comparison/support/comparisonHarness.ts delete mode 100644 src/tests/playwrightConfig.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 6669c4016d6..2f7ee6cd144 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -55,24 +55,20 @@ jobs: npm run build --if-present - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium webkit + run: npx playwright install chromium --only-shell - name: Install poppler for PDF snapshots run: sudo apt-get install -y poppler-utils - 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/ - blob-report/ + path: test-results/ retention-days: 7 summary: diff --git a/package-lock.json b/package-lock.json index de64c0f1e7d..15aba47608c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,7 +91,6 @@ "yjs": "^13.6.33" }, "devDependencies": { - "@axe-core/playwright": "^4.13.0", "@nextcloud/babel-config": "^2.0.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/e2e-test-server": "^0.6.1", @@ -243,19 +242,6 @@ "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": "8.0.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", @@ -7881,16 +7867,6 @@ "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", diff --git a/package.json b/package.json index fae02f62cb1..71869f3db5c 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "yjs": "^13.6.33" }, "devDependencies": { - "@axe-core/playwright": "^4.13.0", "@nextcloud/babel-config": "^2.0.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/e2e-test-server": "^0.6.1", diff --git a/playwright.config.ts b/playwright.config.ts index f62d2c65b2b..d16c75500fb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,57 +2,11 @@ * 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. */ @@ -70,48 +24,22 @@ 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'], - ['json', { outputFile: 'test-results/results.json' }], - ['line'], - ['github'], - ] as ReporterDescription[], + reporter: [['blob'], ['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: COMPARISON_BASE_URL, + baseURL: process.env.baseURL ?? 'http://localhost:8089/index.php/', // record traces but only keep them when the test fails trace: 'on-first-retry', }, @@ -119,13 +47,32 @@ export default defineConfig({ projects: [ { name: 'chromium', - testIgnore: COMPARISON_TESTS, use: { ...devices['Desktop Chrome'], }, }, - ...comparisonProjects(), ], - webServer: comparisonWebServer(), + 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/, + }, + }, }) diff --git a/playwright/comparison/comparison.spec.ts b/playwright/comparison/comparison.spec.ts deleted file mode 100644 index 2e4eab7f681..00000000000 --- a/playwright/comparison/comparison.spec.ts +++ /dev/null @@ -1,792 +0,0 @@ -/** - * 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 deleted file mode 100644 index 17961fc0ecb..00000000000 --- a/playwright/comparison/fixtures.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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 deleted file mode 100644 index 201a0a7b75e..00000000000 --- a/playwright/comparison/support/comparisonHarness.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * 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/tests/playwrightConfig.spec.ts b/src/tests/playwrightConfig.spec.ts deleted file mode 100644 index 3811aa2248e..00000000000 --- a/src/tests/playwrightConfig.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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) - }) -}) From 3e262d1e652174a59796455e3bd546475b4bd8f2 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 28 Sep 2026 16:35:43 +0200 Subject: [PATCH 02/14] refactor(comparison): drop the Markdown source view The literal source comparison ran a line diff in a module worker with cancellation, gap paging, word-level emphasis under time budgets and visible tokens for control characters and line endings. That is a lot of machinery for a view the rendered comparison rarely needs. Remove the view, its worker and the worker build config. The plain source fallback stays for the case where no rendered comparison can be built; it now shows both snapshots verbatim without display limits. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5.5 --- src/comparison/markdownSourceComparison.ts | 543 ------------------ .../markdownSourceComparison.worker.ts | 12 - .../markdownSourceComparisonProtocol.ts | 31 - src/comparison/markdownSourceDisplay.ts | 102 ---- src/components/MarkdownContentComparison.vue | 69 +-- src/components/MarkdownSourceComparison.vue | 529 ----------------- src/components/MarkdownSourceFallback.vue | 13 +- .../MarkdownSourceComponent.spec.ts | 187 ------ .../comparison/MarkdownSourceFallback.spec.ts | 30 - .../createMarkdownContentComparison.spec.ts | 9 +- .../markdownSourceComparison.spec.ts | 214 ------- .../markdownSourceComparisonWorker.spec.ts | 30 - vite.config.ts | 9 - 13 files changed, 14 insertions(+), 1764 deletions(-) delete mode 100644 src/comparison/markdownSourceComparison.ts delete mode 100644 src/comparison/markdownSourceComparison.worker.ts delete mode 100644 src/comparison/markdownSourceComparisonProtocol.ts delete mode 100644 src/comparison/markdownSourceDisplay.ts delete mode 100644 src/components/MarkdownSourceComparison.vue delete mode 100644 src/tests/comparison/MarkdownSourceComponent.spec.ts delete mode 100644 src/tests/comparison/MarkdownSourceFallback.spec.ts delete mode 100644 src/tests/comparison/markdownSourceComparison.spec.ts delete mode 100644 src/tests/comparison/markdownSourceComparisonWorker.spec.ts diff --git a/src/comparison/markdownSourceComparison.ts b/src/comparison/markdownSourceComparison.ts deleted file mode 100644 index 1d32c2c2752..00000000000 --- a/src/comparison/markdownSourceComparison.ts +++ /dev/null @@ -1,543 +0,0 @@ -/** - * 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 - -/** - * Compare literal source with original line numbers and line endings. - * Size or work limits return a limited result; cancellation and worker failures reject. - * - * @param before Earlier raw Markdown. - * @param after Later raw Markdown. - * @param signal Optional cancellation signal; abort terminates an active worker. - */ -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, - ) -} - -/** - * Read a bounded page of unchanged source rows, normalizing offsets and capping the page size. - * - * @param before Original earlier source. - * @param after Original later source. - * @param gap Gap from the corresponding source model. - * @param maximumRows Requested page size, capped at the source gap limit. - * @param offset Row offset within the gap, clamped to its bounds. - */ -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 deleted file mode 100644 index f4994c4966f..00000000000 --- a/src/comparison/markdownSourceComparison.worker.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7dd69d43504..00000000000 --- a/src/comparison/markdownSourceComparisonProtocol.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 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' } - -/** - * Run the synchronous bounded line diff used by the worker, returning ready changes or a limited result. - * - * @param request Normalized source strings and the edit-length/time budgets. - */ -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/comparison/markdownSourceDisplay.ts b/src/comparison/markdownSourceDisplay.ts deleted file mode 100644 index 7ed6e111015..00000000000 --- a/src/comparison/markdownSourceDisplay.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -const visibleControlNames: Readonly> = { - '\t': 'TAB', - '\u00AD': 'SHY', - '\u061C': 'ALM', - '\u0085': 'NEL', - '\u200B': 'ZWSP', - '\u200C': 'ZWNJ', - '\u200D': 'ZWJ', - '\u200E': 'LRM', - '\u200F': 'RLM', - '\u2060': 'WORD JOINER', - '\uFEFF': 'BOM', - '\u2028': 'LS', - '\u2029': 'PS', - '\u202A': 'LRE', - '\u202B': 'RLE', - '\u202C': 'PDF', - '\u202D': 'LRO', - '\u202E': 'RLO', - '\u2066': 'LRI', - '\u2067': 'RLI', - '\u2068': 'FSI', - '\u2069': 'PDI', -} - -export const COMPLETE_SOURCE_DISPLAY_LIMITS = Object.freeze({ - maximumInputCharactersPerSide: 1_000_000, - maximumVisibleCharactersPerSide: 1_000_000, - maximumVisibleCharactersPerLine: 20_000, -}) -const LIMITS = COMPLETE_SOURCE_DISPLAY_LIMITS - -function sourcePrefix(value: string, maximumCharacters: number) { - let prefix = value.slice(0, Math.max(0, maximumCharacters)) - const finalCodeUnit = prefix.charCodeAt(prefix.length - 1) - const nextCodeUnit = value.charCodeAt(prefix.length) - if (finalCodeUnit >= 0xD800 && finalCodeUnit <= 0xDBFF - && nextCodeUnit >= 0xDC00 && nextCodeUnit <= 0xDFFF) { - prefix = prefix.slice(0, -1) - } - return prefix -} - -function renderMarkdownSource(source: string, maximumCharacters: number, maximumLineCharacters = Number.POSITIVE_INFINITY) { - let visible = '' - let lineCharacters = 0 - for (const character of source) { - const named = visibleControlNames[character] - const code = character.codePointAt(0)! - let rendered = character - if (named) { - rendered = `⟦${named}⟧` - } else if (character.length === 1 && code >= 0xD800 && code <= 0xDFFF) { - rendered = `⟦U+${code.toString(16).toUpperCase()}⟧` - } else if ((code < 0x20 && character !== '\n' && character !== '\r') || code === 0x7F) { - rendered = `⟦U+${code.toString(16).toUpperCase().padStart(4, '0')}⟧` - } - if (rendered.length > maximumCharacters - visible.length - || (character !== '\n' && character !== '\r' - && rendered.length > maximumLineCharacters - lineCharacters)) { - return { text: visible, complete: false } - } - visible += rendered - lineCharacters = character === '\n' || character === '\r' - ? 0 - : lineCharacters + rendered.length - } - return { text: visible, complete: true } -} - -/** - * Render control and bidi characters as visible tokens without splitting surrogate pairs or tokens. - * - * @param source Literal source to display. - * @param maximumCharacters Maximum rendered UTF-16 length; excess output is omitted. - */ -export function displayMarkdownSource(source: string, maximumCharacters = Number.POSITIVE_INFINITY) { - return renderMarkdownSource(source, maximumCharacters).text -} - -/** - * Render source under input, output and per-line limits, reporting whether any content was omitted. - * - * @param source Raw source; a truncated prefix does not split a valid surrogate pair. - */ -export function displayBoundedMarkdownSource(source: string) { - const input = sourcePrefix(source, LIMITS.maximumInputCharactersPerSide) - const visible = renderMarkdownSource( - input, - LIMITS.maximumVisibleCharactersPerSide, - LIMITS.maximumVisibleCharactersPerLine, - ) - return { - text: visible.text, - truncated: input.length < source.length || !visible.complete, - } -} diff --git a/src/components/MarkdownContentComparison.vue b/src/components/MarkdownContentComparison.vue index a7ed89846fa..eb694103291 100644 --- a/src/components/MarkdownContentComparison.vue +++ b/src/components/MarkdownContentComparison.vue @@ -99,18 +99,7 @@ }}
-

- {{ - t('text', 'No rendered differences — Markdown syntax differs.') - }} -

- - {{ t('text', 'Open Markdown source') }} - + {{ t('text', 'No rendered differences — Markdown syntax differs.') }}

{{ t('text', 'No differences.') }} @@ -164,35 +153,17 @@ - -

-

- {{ - t( - 'text', - 'Source compares literal Markdown, so its change groups can differ from rendered changes.', - ) - }} -

- - -
- - diff --git a/src/components/MarkdownSourceFallback.vue b/src/components/MarkdownSourceFallback.vue index 8116444eb68..f0feb7fdcc4 100644 --- a/src/components/MarkdownSourceFallback.vue +++ b/src/components/MarkdownSourceFallback.vue @@ -6,17 +6,14 @@