From 1ffecf6a44804479433e1732c8fd5a1cd8252374 Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Wed, 16 Sep 2026 16:40:24 +0700 Subject: [PATCH 1/2] ci(collectives): adopt verified Text comparison behavior Pin the tested Text candidates and verify the initial view, rendered targets, served assets, and delayed-image behavior in the actual comparison dialog. Assisted-by: Codex:gpt-6-astra Signed-off-by: Hoang Pham --- .github/workflows/cypress-e2e.yml | 2 +- .github/workflows/playwright.yml | 8 +- playwright/e2e/version-comparison.spec.ts | 190 +++++++++++++++++++++- 3 files changed, 193 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cypress-e2e.yml b/.github/workflows/cypress-e2e.yml index db4808f66..d9463d1ac 100644 --- a/.github/workflows/cypress-e2e.yml +++ b/.github/workflows/cypress-e2e.yml @@ -72,7 +72,7 @@ jobs: server-major: '' build-text: '1' text-app-repository: 'nextcloud/text' - text-app-ref: 'b5ff1b0ce8d38b8a8dfdd6acf7b3f36c3fa2e272' + text-app-ref: '9d3b174bf8cad2a96f3bb95896db35eefb321e5c' env: PUPPETEER_SKIP_DOWNLOAD: true diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 1a8c9da90..921997ed9 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -34,7 +34,7 @@ jobs: project-args: '--project=chromium --project=comparison-chromium' semantic-e2e: '1' text-repository: 'nextcloud/text' - text-ref: 'b5ff1b0ce8d38b8a8dfdd6acf7b3f36c3fa2e272' + text-ref: '9d3b174bf8cad2a96f3bb95896db35eefb321e5c' steps: - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -77,7 +77,7 @@ jobs: run: npx playwright test ${{ matrix.project-args }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: COLLECTIVES_SEMANTIC_E2E: ${{ matrix.semantic-e2e }} - COLLECTIVES_COMPARISON_INITIAL_VIEW: changes + COLLECTIVES_COMPARISON_INITIAL_VIEW: documents PLAYWRIGHT_NC_SERVER_BRANCH: ${{ matrix.server-branch }} PLAYWRIGHT_TEXT_REPOSITORY: ${{ matrix.text-repository }} PLAYWRIGHT_TEXT_REF: ${{ matrix.text-ref }} @@ -137,11 +137,11 @@ jobs: run: ./node_modules/.bin/playwright test playwright/e2e/version-comparison.spec.ts --project=comparison-chromium --workers=1 env: COLLECTIVES_SEMANTIC_E2E: '1' - COLLECTIVES_COMPARISON_INITIAL_VIEW: changes + COLLECTIVES_COMPARISON_INITIAL_VIEW: documents NEXTCLOUD_E2E_LOG_FILE: test-results/nextcloud.log PLAYWRIGHT_NC_SERVER_BRANCH: stable35 PLAYWRIGHT_TEXT_REPOSITORY: nextcloud/text - PLAYWRIGHT_TEXT_REF: 'b07c97d488f7a2c44edea8b2ad3b88a85f6612bf' + PLAYWRIGHT_TEXT_REF: '763af08c27a3f2b625c7d48b8acaf3d65c155e7c' - name: Upload results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/playwright/e2e/version-comparison.spec.ts b/playwright/e2e/version-comparison.spec.ts index b253f81a1..b70ec7cd8 100644 --- a/playwright/e2e/version-comparison.spec.ts +++ b/playwright/e2e/version-comparison.spec.ts @@ -10,7 +10,7 @@ import type { CollectivePage } from '../support/fixtures/CollectivePage.ts' import { docker, getContainer, runOcc } from '@nextcloud/e2e-test-server/docker' import { test as base, expect, mergeTests, request as requestApi } from '@playwright/test' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { listVersions } from '../../src/apis/dav/davRequests.js' import { test as editorTest } from '../support/fixtures/editor.ts' import { loginAsUser } from '../support/fixtures/random-user.ts' @@ -89,7 +89,12 @@ const test = mergeTests(provisionedTest, editorTest) async function openVersions(page: Page) { const tab = page.locator('#tab-button-versions') if (!await tab.isVisible()) { - await page.locator('button.page-sidebar-button').click() + const sidebar = page.locator('button.page-sidebar-button') + if (await sidebar.isVisible()) { + await sidebar.click() + } else { + await page.getByTitle('Open version history', { exact: true }).click() + } } await tab.click() } @@ -1787,6 +1792,187 @@ test.describe('Comparison route recovery and immutable links', () => { }) }) +for (const size of [{ width: 1280 }, { width: 620 }, { width: 1280, modalWidth: 700 }]) { + test(`Opening current target through the actual dialog transfer at ${size.width}px viewport and ${size.modalWidth ?? 'automatic'}px modal`, async ({ collective, user, page }, testInfo) => { + await page.setViewportSize({ width: size.width, height: 900 }) + const collectivePage = await collective.createPage({ title: 'c599-e2e-initial-location', user, page }) + const prefix = Array.from({ length: 65 }, (_, index) => `Unchanged paragraph ${index}. This paragraph precedes the first edit.`).join('\n\n') + await collectivePage.setContent({ content: `${prefix}\n\nThe target is old.`, user, page }) + await page.waitForTimeout(1100) + await collectivePage.setContent({ content: `${prefix}\n\nThe target is new.`, user, page }) + await collectivePage.open() + await openVersions(page) + await page.evaluate(() => { + const probeWindow = window as typeof window & { __c599Opening?: Array<{ milliseconds: number, connectedAtReturn: boolean }> } + probeWindow.__c599Opening = [] + const factory = window.OCA.Text.createMarkdownContentComparison + Object.defineProperty(window.OCA.Text, 'createMarkdownContentComparison', { + configurable: true, + value: async (options: { el: HTMLElement }) => { + const started = performance.now() + const result = await factory(options) + probeWindow.__c599Opening!.push({ milliseconds: performance.now() - started, connectedAtReturn: options.el.isConnected }) + return result + }, + }) + }) + const expected = testInfo.config.metadata.comparisonInitialView === 'documents' ? 'Full documents' : 'Changes' + const opener = page.getByRole('button', { name: 'Compare versions…' }) + for (let opening = 0; opening < 3; opening++) { + await opener.click() + const dialog = page.getByRole('dialog', { name: 'Compare versions' }) + if (size.modalWidth) { + await page.locator('.modal-container:has(.version-comparison-dialog)').evaluate((element: HTMLElement, width) => { + element.style.width = `${width}px` + }, size.modalWidth) + } + await dialog.getByRole('button', { name: 'Compare', exact: true }).click() + await expect(dialog.getByRole('tab', { name: expected, exact: true })).toHaveAttribute('aria-selected', 'true') + await expect.poll(() => dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true) + if (expected === 'Changes') { + await dialog.getByRole('tab', { name: 'Full documents' }).click() + } + await expect.poll(() => dialog.locator('.text-comparison__document-scroller').evaluateAll((scrollers) => scrollers.some((scroller) => { + const pane = scroller.getBoundingClientRect() + if (pane.width <= 0 || pane.height <= 0) { + return false + } + return Array.from(scroller.querySelectorAll('.text-comparison-change--current')).some((target) => { + const rectangle = target.getBoundingClientRect() + return rectangle.height > 0 && rectangle.top >= pane.top - 2 && rectangle.bottom <= pane.bottom + 2 + }) + }))).toBe(true) + await expect(dialog.locator('.text-comparison__document--before')).toContainText('The target is old.') + await expect(dialog.locator('.text-comparison__document--after')).toContainText('The target is new.') + await expect(dialog.locator('.text-comparison')).toHaveClass(new RegExp(`text-comparison--${size.width < 760 || size.modalWidth ? 'single' : 'paired'}`)) + await page.locator('.modal-mask:has(.version-comparison-dialog) button.modal-container__close').click() + await expect(dialog).toHaveCount(0) + await expect(opener).toBeFocused() + } + const measurements = await page.evaluate(() => (window as typeof window & { __c599Opening: Array<{ milliseconds: number, connectedAtReturn: boolean }> }).__c599Opening) + expect(measurements).toHaveLength(3) + await testInfo.attach('opening-measurements.json', { body: JSON.stringify({ size, expected, measurements }), contentType: 'application/json' }) + }) +} + +for (const fixture of [ + { name: 'formatting only', before: 'Keep this text.', after: 'Keep **this** text.', edits: true }, + { name: 'identical', before: '# Same document', after: '# Same document', edits: false }, + { name: 'syntax only', before: '# Same document', after: '# Same document #', edits: false }, +]) { + test(`Initial comparison state in Collectives for ${fixture.name}`, async ({ collective, user, page }, testInfo) => { + const collectivePage = await collective.createPage({ title: 'c599-e2e-initial-state', user, page }) + for (const [index, content] of [fixture.before, 'Intermediate historical revision', fixture.after].entries()) { + if (index > 0) { + await page.waitForTimeout(1100) + } + await collectivePage.setContent({ content, user, page }) + } + await collectivePage.open() + await openVersions(page) + await page.getByRole('button', { name: 'Compare versions…' }).click() + const dialog = page.getByRole('dialog', { name: 'Compare versions' }) + await dialog.locator('select').nth(0).selectOption({ index: 2 }) + await dialog.getByRole('button', { name: 'Compare', exact: true }).click() + const documents = fixture.edits && testInfo.config.metadata.comparisonInitialView === 'documents' + await expect(dialog.getByRole('tab', { name: documents ? 'Full documents' : 'Changes', exact: true })).toHaveAttribute('aria-selected', 'true') + if (documents) { + await expect(dialog.locator('.text-comparison-change--formatting').first()).toBeVisible() + } else if (!fixture.edits) { + await expect(dialog).toContainText(fixture.name === 'syntax only' ? 'No rendered differences — Markdown syntax differs.' : 'No differences') + } + }) +} + +test('Installed Text serves the normal Source chunk and module worker with extractable row labels', async ({ collective, user, page }, testInfo) => { + const assets: Array> = [] + page.on('response', (response) => { + if (/\/text\/js\/.*\.m?js(?:\?|$)/.test(response.url())) { + assets.push(response.body().then((body) => ({ + url: response.url(), + status: response.status(), + contentType: response.headers()['content-type'] ?? '', + sha256: createHash('sha256').update(body).digest('hex'), + }))) + } + }) + await openSeededComparison(collective, user, page, 'c599-e2e-installed-source-assets') + await page.getByRole('tab', { name: 'Markdown source' }).click() + const source = page.locator('.text-source-comparison') + await expect(source.locator('[data-source-operation="removed"]').first()).toHaveAttribute('aria-label', /^Removed line \d+$/) + await expect(source.locator('[data-source-operation="added"]').first()).toHaveAttribute('aria-label', /^Added line \d+$/) + await expect(source.locator('.text-source-comparison__fallback')).toHaveCount(0) + await page.setViewportSize({ width: 620, height: 900 }) + await expect(source.locator('[role="tab"]').filter({ hasText: 'After' })).toBeVisible() + await page.waitForLoadState('networkidle') + const loaded = await Promise.all(assets) + expect(loaded.some(({ url }) => /markdownSourceComparison\.worker-/.test(url))).toBe(true) + expect(loaded.some(({ url }) => /MarkdownSourceComparison-/.test(url))).toBe(true) + for (const asset of loaded) { + expect(asset.status, asset.url).toBe(200) + expect(asset.contentType, asset.url).toMatch(/(?:java|ecma)script/i) + } + await testInfo.attach('served-text-assets.json', { body: JSON.stringify(loaded), contentType: 'application/json' }) +}) + +for (const interrupted of [false, true]) { + test(`A late image preserves the reader position and explicit change navigation (reader interrupted: ${interrupted})`, async ({ collective, user, page: authenticatedPage, baseURL }, testInfo) => { + test.skip(testInfo.config.metadata.comparisonInitialView !== 'documents', 'Requires the adopted Documents default') + const collectivePage = await collective.createPage({ title: 'c599-e2e-opening-image', user, page: authenticatedPage }) + const imageUrl = new URL('/comparison-opening-image.svg', baseURL).href + const prefix = `![Opening image](${imageUrl})\n\n${Array.from({ length: 4 }, (_, index) => `Unchanged paragraph ${index}.`).join('\n\n')}` + await collectivePage.setContent({ content: `${prefix}\n\nBefore ending.`, user, page: authenticatedPage }) + await authenticatedPage.waitForTimeout(1100) + await collectivePage.setContent({ content: `${prefix}\n\nAfter ending.`, user, page: authenticatedPage }) + const context = await freshAuthenticatedContext(authenticatedPage, 'block') + const page = await context.newPage() + let release!: () => void + const imageReady = new Promise((resolve) => { + release = resolve + }) + let imageRequested = false + await page.route('**/comparison-opening-image.svg', async (route) => { + imageRequested = true + await imageReady + await route.fulfill({ contentType: 'image/svg+xml', body: '' }) + }) + try { + await page.goto(collectivePage.getPageUrl(), { waitUntil: 'domcontentloaded' }) + await expect(page.locator('[data-cy-collectives="reader"] .ProseMirror')).toBeVisible() + await openVersions(page) + await page.getByRole('button', { name: 'Compare versions…' }).click() + await page.getByRole('dialog', { name: 'Compare versions' }).getByRole('button', { name: 'Compare', exact: true }).click() + await expect(page.getByRole('tab', { name: 'Full documents' })).toHaveAttribute('aria-selected', 'true') + await expect.poll(() => imageRequested).toBe(true) + const scroller = page.locator('.text-comparison__document--before .text-comparison__document-scroller') + if (interrupted) { + await scroller.hover() + await page.mouse.wheel(0, -100_000) + await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBe(0) + } + await page.evaluate(async () => { + for (let frame = 0; frame < 4; frame++) { + await new Promise(requestAnimationFrame) + } + }) + const settledScroll = await scroller.evaluate((element) => element.scrollTop) + release() + await expect(page.locator('.text-comparison__document--before img').first()).toHaveJSProperty('naturalHeight', 1600) + await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBe(settledScroll) + await page.getByRole('tab', { name: 'Changes', exact: true }).click() + await page.locator('[data-comparison-select]').first().click() + await expect.poll(() => scroller.evaluate((element) => { + const target = element.querySelector('[data-comparison-change][aria-current="true"]')!.getBoundingClientRect() + const viewport = element.getBoundingClientRect() + return target.top >= viewport.top && target.bottom <= viewport.bottom + })).toBe(true) + } finally { + release() + await context.close() + } + }) +} + test('AUD-06 legacy runtime opens original historical and current Viewer panes from the sidebar', { tag: '@viewer-fallback' }, async ({ collective, user, page }) => { const collectivePage = await collective.createPage({ title: 'c599-e2e-legacy-sidebar', user, page }) await seedVersionPair(collectivePage, user, page) From 279cde74ae89e775074b4f479a293bef50827f2d Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Wed, 16 Sep 2026 21:11:42 +0700 Subject: [PATCH 2/2] test(collectives): await the requested Source assets Observe the Source component and module worker responses after opening the comparison. Verify their served bytes through the observed URLs because Chromium does not reliably expose module-worker response bodies. Assisted-by: Codex:gpt-6-astra Signed-off-by: Hoang Pham --- playwright/e2e/version-comparison.spec.ts | 30 +++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/playwright/e2e/version-comparison.spec.ts b/playwright/e2e/version-comparison.spec.ts index b70ec7cd8..639bb5e49 100644 --- a/playwright/e2e/version-comparison.spec.ts +++ b/playwright/e2e/version-comparison.spec.ts @@ -1885,29 +1885,29 @@ for (const fixture of [ } test('Installed Text serves the normal Source chunk and module worker with extractable row labels', async ({ collective, user, page }, testInfo) => { - const assets: Array> = [] - page.on('response', (response) => { - if (/\/text\/js\/.*\.m?js(?:\?|$)/.test(response.url())) { - assets.push(response.body().then((body) => ({ - url: response.url(), - status: response.status(), - contentType: response.headers()['content-type'] ?? '', - sha256: createHash('sha256').update(body).digest('hex'), - }))) - } - }) await openSeededComparison(collective, user, page, 'c599-e2e-installed-source-assets') + const assets = Promise.all([ + page.waitForResponse(/\/text\/js\/MarkdownSourceComparison-.*\.mjs/), + page.waitForResponse(/\/text\/js\/markdownSourceComparison\.worker-.*\.mjs/), + ]) await page.getByRole('tab', { name: 'Markdown source' }).click() + const loaded = await Promise.all((await assets).map(async (response) => { + // Chromium does not always expose the body of a module worker response. + const served = await page.request.get(response.url(), { failOnStatusCode: true }) + expect(served.status()).toBe(200) + return { + url: response.url(), + status: response.status(), + contentType: response.headers()['content-type'] ?? '', + sha256: createHash('sha256').update(await served.body()).digest('hex'), + } + })) const source = page.locator('.text-source-comparison') await expect(source.locator('[data-source-operation="removed"]').first()).toHaveAttribute('aria-label', /^Removed line \d+$/) await expect(source.locator('[data-source-operation="added"]').first()).toHaveAttribute('aria-label', /^Added line \d+$/) await expect(source.locator('.text-source-comparison__fallback')).toHaveCount(0) await page.setViewportSize({ width: 620, height: 900 }) await expect(source.locator('[role="tab"]').filter({ hasText: 'After' })).toBeVisible() - await page.waitForLoadState('networkidle') - const loaded = await Promise.all(assets) - expect(loaded.some(({ url }) => /markdownSourceComparison\.worker-/.test(url))).toBe(true) - expect(loaded.some(({ url }) => /MarkdownSourceComparison-/.test(url))).toBe(true) for (const asset of loaded) { expect(asset.status, asset.url).toBe(200) expect(asset.contentType, asset.url).toMatch(/(?:java|ecma)script/i)