From 5e7755efd2d4da4d7c824d1f91a2c26df05ce91f Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 19 Oct 2026 12:16:27 +0200 Subject: [PATCH 1/9] refactor(versions): compare page versions in the viewer Text shows the differences between two Markdown versions in the viewer now, so the comparison dialog of its own is no longer needed. Comparing a version with the current one opens the viewer again. This removes the comparison dialog with its snapshot loading, caching and size limits, as well as the shareable comparison links. Links with `compareFrom` and `compareTo` open the page without a comparison now. Kept from the previous implementation: * the versions tab for members without edit permissions * reloading the versions when the page timestamp changes * ignoring outdated versions responses The tests for the removed code and the separate CI setup for them are removed as well. The Cypress page versions tests are back to the lean version from before. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Jonas --- .github/workflows/cypress-e2e.yml | 8 - .github/workflows/playwright.yml | 12 +- .gitignore | 1 - cypress.config.ts | 8 +- cypress/e2e/page-versions-components.cy.js | 410 ----- cypress/e2e/page-versions.spec.js | 1572 +---------------- cypress/support/commands.js | 4 +- playwright.config.ts | 33 +- playwright/e2e/version-comparison.spec.ts | 739 -------- .../helpers/versionComparisonFixtures.ts | 56 - src/components/CollectiveContainer.vue | 10 +- src/components/Page/PageTitleBar.vue | 3 +- src/components/Page/TextEditor.vue | 12 - src/components/PageSidebar.vue | 52 - .../PageSidebar/SidebarTabVersions.vue | 87 +- .../PageSidebar/VersionComparisonDialog.vue | 625 ------- src/components/PageSidebar/VersionEntry.vue | 3 +- src/stores/versions.js | 46 +- src/tests/playwrightConfig.spec.ts | 65 - src/tests/util/boundedSnapshotBody.spec.js | 152 -- src/tests/util/versionComparison.spec.js | 509 ------ .../util/versionComparisonE2eFixtures.spec.ts | 80 - src/tests/util/versionComparisonRoute.spec.js | 126 -- src/tests/util/versionsStore.spec.js | 136 -- src/tests/util/viewer.spec.ts | 73 + src/util/versionComparison.js | 351 ---- src/util/versionComparisonRoute.js | 87 - src/util/viewer.ts | 10 - 28 files changed, 137 insertions(+), 5133 deletions(-) delete mode 100644 cypress/e2e/page-versions-components.cy.js delete mode 100644 playwright/e2e/version-comparison.spec.ts delete mode 100644 playwright/support/helpers/versionComparisonFixtures.ts delete mode 100644 src/components/PageSidebar/VersionComparisonDialog.vue delete mode 100644 src/tests/playwrightConfig.spec.ts delete mode 100644 src/tests/util/boundedSnapshotBody.spec.js delete mode 100644 src/tests/util/versionComparison.spec.js delete mode 100644 src/tests/util/versionComparisonE2eFixtures.spec.ts delete mode 100644 src/tests/util/versionComparisonRoute.spec.js delete mode 100644 src/tests/util/versionsStore.spec.js create mode 100644 src/tests/util/viewer.spec.ts delete mode 100644 src/util/versionComparison.js delete mode 100644 src/util/versionComparisonRoute.js diff --git a/.github/workflows/cypress-e2e.yml b/.github/workflows/cypress-e2e.yml index ea1fac1012..bb67ff5850 100644 --- a/.github/workflows/cypress-e2e.yml +++ b/.github/workflows/cypress-e2e.yml @@ -65,10 +65,8 @@ jobs: include: - server-versions: 'stable33' server-major: '33' - semantic-e2e: '0' - server-versions: 'master' server-major: '' - semantic-e2e: '1' env: PUPPETEER_SKIP_DOWNLOAD: true @@ -188,11 +186,6 @@ jobs: php-versions: ['8.4'] databases: ['sqlite'] server-versions: ['stable33', 'master'] - include: - - server-versions: 'stable33' - semantic-e2e: '0' - - server-versions: 'master' - semantic-e2e: '1' name: runner ${{ matrix.containers }} (${{ matrix.server-versions }}) @@ -275,7 +268,6 @@ jobs: npx cypress run --record false --config defaultCommandTimeout=20000,video=false env: CYPRESS_ncVersion: ${{ matrix.server-versions }} - CYPRESS_semanticE2E: ${{ matrix.semantic-e2e }} # `SPLIT` needs to match count of `containers` in the matrix above SPLIT: 4 SPLIT_INDEX: ${{ env.container_index }} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index aedac9d7e3..af17199da0 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,13 +20,6 @@ jobs: server-branch: ['stable33', 'master'] shardIndex: [1, 2, 3, 4] shardTotal: [4] - include: - - server-branch: 'stable33' - project-args: '--project=chromium --project=comparison-viewer-chromium' - semantic-e2e: '0' - - server-branch: 'master' - project-args: '--project=chromium --project=comparison-chromium' - semantic-e2e: '1' steps: - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -63,12 +56,11 @@ jobs: npm run build --if-present - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium + run: npx playwright install chromium --only-shell - name: Run Playwright tests - run: npx playwright test ${{ matrix.project-args }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: - COLLECTIVES_SEMANTIC_E2E: ${{ matrix.semantic-e2e }} PLAYWRIGHT_NC_SERVER_BRANCH: ${{ matrix.server-branch }} - name: Upload results diff --git a/.gitignore b/.gitignore index 935f1ca03d..6e1ebbb5bd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ css/* # Cypress test artifacts cypress/downloads/ -cypress/results/ cypress/screenshots/ cypress/videos/ diff --git a/cypress.config.ts b/cypress.config.ts index c681d22bcf..904dac28a3 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -3,19 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import vue from '@vitejs/plugin-vue' import { defineConfig } from 'cypress' import cypressSplit from 'cypress-split' import vitePreprocessor from 'cypress-vite' -const SEMANTIC_E2E = process.env.CYPRESS_semanticE2E === '1' - export default defineConfig({ viewportWidth: 1280, viewportHeight: 900, e2e: { setupNodeEvents(on, config) { - on('file:preprocessor', vitePreprocessor({ configFile: false, plugins: [vue()] })) + on('file:preprocessor', vitePreprocessor({ configFile: false })) cypressSplit(on, config) return config }, @@ -23,9 +20,6 @@ export default defineConfig({ baseUrl: 'http://localhost:8081/index.php/', specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', }, - expose: { - semanticE2E: SEMANTIC_E2E, - }, defaultCommandTimeout: 7000, retries: { runMode: 2, diff --git a/cypress/e2e/page-versions-components.cy.js b/cypress/e2e/page-versions-components.cy.js deleted file mode 100644 index a31169dd72..0000000000 --- a/cypress/e2e/page-versions-components.cy.js +++ /dev/null @@ -1,410 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { getRootPath } from '@nextcloud/files/dav' -import { registerHandler } from '@nextcloud/viewer' -import { createPinia, setActivePinia } from 'pinia' -import { createApp, h, nextTick, ref } from 'vue' -import { createRouter, createWebHistory } from 'vue-router' -import PageSidebar from '../../src/components/PageSidebar.vue' -import SidebarTabVersions from '../../src/components/PageSidebar/SidebarTabVersions.vue' -import VersionComparisonDialog from '../../src/components/PageSidebar/VersionComparisonDialog.vue' -import { useCollectivesStore } from '../../src/stores/collectives.js' -import { usePagesStore } from '../../src/stores/pages.js' -import { useRootStore } from '../../src/stores/root.js' -import { useVersionsStore } from '../../src/stores/versions.js' -import { VERSION_COMPARISON_LIMITS } from '../../src/util/versionComparison.js' -import { - COMPARISON_WARNING_STATE, - rejectedVersionComparisonRoute, -} from '../../src/util/versionComparisonRoute.js' - -function nextNavigation(router) { - return new Promise((resolve) => { - const remove = router.afterEach(() => { - remove() - resolve() - }) - }) -} - -async function waitFor(condition, message = 'Expected component state was not reached') { - for (let attempt = 0; attempt < 100; attempt++) { - if (condition()) { - return - } - await new Promise((resolve) => setTimeout(resolve)) - } - throw new Error(typeof message === 'function' ? message() : message) -} - -async function mountPageSidebar() { - const pinia = createPinia() - setActivePinia(pinia) - const rootStore = useRootStore() - const collectivesStore = useCollectivesStore() - const pagesStore = usePagesStore() - rootStore.$patch({ collectiveId: 1, pageId: 7, showingSidebar: false }) - collectivesStore.collectivesState = [{ id: 1, name: 'Atlas', canEdit: true, canShare: true, isPageShare: false }] - pagesStore.allPages = { - 1: [{ id: 7, parentId: 0, title: 'Page', fileName: 'Readme.md', timestamp: 1 }], - } - - window.history.replaceState({}, '', '/Atlas/Page?fileId=42&view=grid#rollout') - const router = createRouter({ - history: createWebHistory(), - routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }], - }) - const element = document.createElement('div') - document.body.append(element) - const app = createApp({ render: () => h(PageSidebar) }) - app.use(pinia) - app.use(router) - app.mount(element) - await router.isReady() - await nextTick() - return { app, element, router } -} - -function deferred() { - let resolve - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise - }) - return { promise, resolve } -} - -async function mountSidebarTabVersions(getVersions) { - document.head.setAttribute('data-user', 'component-user') - document.head.setAttribute('data-user-displayname', 'Component User') - window.history.replaceState({}, '', '/Atlas/Page') - const router = createRouter({ - history: createWebHistory(), - routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }], - }) - const pinia = createPinia() - setActivePinia(pinia) - const rootStore = useRootStore() - const collectivesStore = useCollectivesStore() - const pagesStore = usePagesStore() - const versionsStore = useVersionsStore() - rootStore.$patch({ collectiveId: 1, pageId: 7, showingSidebar: true }) - collectivesStore.collectivesState = [{ id: 1, name: 'Atlas', canEdit: true, canShare: true, isPageShare: false }] - pagesStore.allPages = { - 1: [{ id: 7, parentId: 0, title: 'Page', fileName: 'Readme.md', timestamp: 1, size: 10 }], - } - Object.defineProperty(versionsStore, 'currentVersion', { - configurable: true, - value: { fileId: 7, mtime: 1000, size: 10, source: '/current', url: '/current' }, - }) - versionsStore.getVersions = getVersions - const pageId = ref(7) - const component = ref() - const element = document.createElement('div') - document.body.append(element) - const app = createApp({ - render: () => h(SidebarTabVersions, { - ref: component, - pageId: pageId.value, - pageTimestamp: 1, - }), - }) - app.use(pinia) - app.use(router) - app.mount(element) - await router.isReady() - await nextTick() - return { app, component, element, pageId, rootStore } -} - -async function mountVersionComparisonDialog(currentVersion, versions) { - const pinia = createPinia() - setActivePinia(pinia) - const versionsStore = useVersionsStore(pinia) - window.history.replaceState({}, '', '/Atlas/Page') - const router = createRouter({ - history: createWebHistory(), - routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }], - }) - const component = ref() - const element = document.createElement('div') - document.body.append(element) - const app = createApp({ - render: () => h(VersionComparisonDialog, { - ref: component, - currentVersion, - versions, - filePath: '/Atlas/Page.md', - }), - }) - app.use(pinia) - app.use(router) - app.mount(element) - await router.isReady() - await nextTick() - Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: Cypress.config('viewportWidth') }) - window.dispatchEvent(new Event('resize')) - return { app, component, element, router, versionsStore } -} - -function comparisonFixture(size = 10) { - const currentVersion = { fileId: 7, filename: '/Atlas/Page.md', mime: 'text/markdown', mtime: 3000, size, source: `${window.location.origin}/remote.php/dav${getRootPath()}/current?file=7`, url: `/remote.php/dav${getRootPath()}/current?file=7` } - const historical = { fileVersion: '1', label: 'Old', mtime: 1000, size, source: '/historical?file=7', url: '/historical?file=7' } - const currentSnapshot = { ...currentVersion, fileVersion: '3', isCurrentSnapshot: true } - return { currentVersion, historical, versions: [historical, currentSnapshot] } -} - -function registerMarkdownViewerHandler() { - registerHandler({ - id: 'component-markdown', - displayName: 'Markdown', - tagName: 'component-markdown-viewer', - enabled: (nodes) => nodes.every((node) => node.mime === 'text/markdown'), - onInit: () => {}, - canCompare: () => true, - }) -} - -describe('Mounted page version components', () => { - it('rejects an invalid comparison without corrupting real router history', () => { - cy.then(async () => { - const { app, element, router } = await mountPageSidebar() - const invalid = { - path: '/Atlas/Page', - query: { fileId: '42', view: 'grid', compareFrom: 'broken' }, - hash: '#rollout', - } - const rejected = rejectedVersionComparisonRoute(invalid, { - back: '/stale-back', - current: '/Atlas/Page?fileId=42&view=grid&compareFrom=broken#rollout', - forward: '/stale-forward', - position: 99, - }) - expect(rejected.state).to.deep.equal({ - [COMPARISON_WARNING_STATE]: JSON.stringify(['broken', undefined]), - }) - - await router.push(invalid) - await waitFor(() => router.currentRoute.value.query.compareFrom === undefined) - expect(router.currentRoute.value.fullPath).to.equal('/Atlas/Page?fileId=42&view=grid#rollout') - expect(window.history.state[COMPARISON_WARNING_STATE]).to.equal(JSON.stringify(['broken', undefined])) - - await router.push('/Atlas/Next?step=1#next') - const back = nextNavigation(router) - router.back() - await back - expect(router.currentRoute.value.fullPath).to.equal('/Atlas/Page?fileId=42&view=grid#rollout') - - const forward = nextNavigation(router) - router.forward() - await forward - expect(router.currentRoute.value.fullPath).to.equal('/Atlas/Next?step=1#next') - expect(window.history.state).to.include.keys('back', 'current', 'forward', 'position') - - app.unmount() - element.remove() - }) - }) - - it('keeps the active page loading while a stale page request completes', () => { - cy.then(async () => { - const consoleError = Cypress.sinon.spy(console, 'error') - const first = deferred() - const second = deferred() - const getVersions = Cypress.sinon.stub() - getVersions.withArgs(7).returns(first.promise) - getVersions.withArgs(8).returns(second.promise) - const mounted = await mountSidebarTabVersions(getVersions) - - mounted.pageId.value = 8 - await waitFor(() => getVersions.callCount === 2) - first.resolve([]) - await first.promise - await nextTick() - expect(mounted.component.value.loadPending).to.equal(true) - expect(mounted.rootStore.loading('versions')).to.equal(true) - - second.resolve([]) - await second.promise - await waitFor(() => mounted.rootStore.loading('versions') === false) - expect(mounted.component.value.loadPending).to.equal(false) - expect(mounted.rootStore.loading('versions')).to.equal(false) - expect(mounted.component.value.error).to.equal('') - expect(consoleError).not.to.have.been.calledWithMatch('Failed to get page versions') - - mounted.app.unmount() - mounted.element.remove() - consoleError.restore() - }) - }) - - it('reports an expired selection when current preparation removes its historical version', () => { - cy.then(async () => { - const fixture = comparisonFixture() - window.OCA ??= {} - const createComparison = Cypress.sinon.stub().resolves({ destroy: Cypress.sinon.stub() }) - window.OCA.Text = { createMarkdownContentComparison: createComparison } - const fetchSnapshot = Cypress.sinon.stub(window, 'fetch') - const mounted = await mountVersionComparisonDialog(fixture.currentVersion, fixture.versions) - mounted.versionsStore.registerCurrentSnapshotPreparer(Cypress.sinon.stub().resolves([fixture.versions[1]])) - - try { - mounted.component.value.openSelector() - await waitFor(() => [...document.querySelectorAll('.modal-wrapper button')] - .some(({ textContent }) => textContent.trim() === 'Compare')) - const compare = [...document.querySelectorAll('.modal-wrapper button')] - .find(({ textContent }) => textContent.trim() === 'Compare') - compare.click() - - await waitFor(() => document.querySelector('.version-comparison-dialog [role="alert"]')) - expect(document.querySelector('.version-comparison-dialog [role="alert"]').textContent) - .to.contain('One of the selected versions has expired or was removed.') - expect(fetchSnapshot).not.to.have.been.called - expect(createComparison).not.to.have.been.called - expect(new URL(window.location.href).searchParams.has('compareFrom')).to.equal(false) - expect(new URL(window.location.href).searchParams.has('compareTo')).to.equal(false) - } finally { - mounted.app.unmount() - mounted.element.remove() - fetchSnapshot.restore() - } - }) - }) - - it('shows routed Viewer preparation failure before any snapshot read', () => { - cy.then(async () => { - const fixture = comparisonFixture() - window.OCA ??= {} - window.OCA.Text = {} - registerMarkdownViewerHandler() - const fetchSnapshot = Cypress.sinon.stub(window, 'fetch') - const mounted = await mountVersionComparisonDialog(fixture.currentVersion, fixture.versions) - - await mounted.component.value.openRoutedPair({ from: 'version:1', to: 'current:3' }) - await waitFor(() => document.querySelector('.version-comparison-dialog [role="alert"]')) - expect(document.querySelector('.version-comparison-dialog [role="alert"]').textContent) - .to.contain('Could not save current changes before comparison. Please try again.') - expect(fetchSnapshot).not.to.have.been.called - - mounted.app.unmount() - mounted.element.remove() - fetchSnapshot.restore() - }) - }) - - it('publishes fresh bounded current previews and retries through the mounted dialog', () => { - cy.then(async () => { - const fixture = comparisonFixture(VERSION_COMPARISON_LIMITS.maximumSnapshotBytes + 1) - window.OCA ??= {} - window.OCA.Text = { createMarkdownContentComparison: Cypress.sinon.stub() } - let currentLoads = 0 - const fetchSnapshot = Cypress.sinon.stub(window, 'fetch').callsFake(async (url) => { - const content = String(url).includes('/current') - ? `fresh-current-${++currentLoads}` - : 'historical-preview' - return new Response(content) - }) - const mounted = await mountVersionComparisonDialog(fixture.currentVersion, fixture.versions) - const prepare = Cypress.sinon.stub().resolves(fixture.versions) - mounted.versionsStore.registerCurrentSnapshotPreparer(prepare) - - mounted.component.value.openFor(fixture.historical) - await waitFor( - () => document.querySelectorAll('.version-comparison-dialog__preview').length === 2, - () => `Expected two previews; prepare calls: ${prepare.callCount}; fetch calls: ${fetchSnapshot.callCount}; dialog: ${document.querySelector('.version-comparison-dialog')?.textContent}`, - ) - expect(document.querySelector('.version-comparison-dialog').textContent).to.contain('fresh-current-1') - const urls = fetchSnapshot.args.map(([url]) => String(url)) - expect(urls).to.include('/historical?file=7') - const currentUrl = new URL(urls.find((url) => url.includes('/current'))) - expect(currentUrl.searchParams.get('file')).to.equal('7') - expect(currentUrl.searchParams.get('timestamp')).to.match(/^\d{13}$/) - expect(fetchSnapshot.args.every(([, options]) => options.headers.Range === `bytes=0-${VERSION_COMPARISON_LIMITS.maximumPreviewBytes - 1}`)).to.equal(true) - - const retry = [...document.querySelectorAll('button')] - .find((button) => button.textContent.includes('Retry')) - expect(retry).to.not.equal(undefined) - retry.click() - await waitFor( - () => document.querySelector('.version-comparison-dialog').textContent.includes('fresh-current-2'), - `Expected retry preview; fetch calls: ${fetchSnapshot.callCount}`, - ) - expect(prepare.callCount).to.equal(2) - - mounted.app.unmount() - mounted.element.remove() - fetchSnapshot.restore() - }) - }) - - it('uses the Text renderer size selected at compare time', () => { - cy.then(async () => { - const fixture = comparisonFixture() - window.OCA ??= {} - window.OCA.Text = {} - const fetchSnapshot = Cypress.sinon.stub(window, 'fetch').callsFake(async () => { - let sent = false - return { - body: { getReader: () => ({ - cancel: async () => {}, - read: async () => { - if (sent) { - return { done: true } - } - sent = true - return { done: false, value: new TextEncoder().encode('snapshot') } - }, - }) }, - headers: new Headers(), - ok: true, - status: 200, - } - }) - const mounted = await mountVersionComparisonDialog(fixture.currentVersion, fixture.versions) - mounted.versionsStore.registerCurrentSnapshotPreparer(Cypress.sinon.stub().resolves(fixture.versions)) - try { - mounted.component.value.openSelector() - await waitFor(() => [...document.querySelectorAll('.modal-wrapper button')] - .some(({ textContent }) => textContent.trim() === 'Compare')) - const createComparison = Cypress.sinon.stub().resolves({ destroy: Cypress.sinon.stub() }) - window.OCA.Text.createMarkdownContentComparison = createComparison - const compare = [...document.querySelectorAll('.modal-wrapper button')] - .find(({ textContent }) => textContent.trim() === 'Compare') - compare.click() - - await waitFor( - () => createComparison.calledOnce, - () => `Expected factory call; fetch calls: ${fetchSnapshot.callCount}; dialog: ${document.querySelector('.modal-wrapper')?.textContent}`, - ) - expect(document.querySelector('.modal-wrapper--full')).not.to.equal(null) - } finally { - mounted.app.unmount() - mounted.element.remove() - fetchSnapshot.restore() - } - }) - }) - - it('shows one informational message for current snapshot aliases', () => { - cy.then(async () => { - const fixture = comparisonFixture() - window.OCA ??= {} - window.OCA.Text = {} - const mounted = await mountVersionComparisonDialog(fixture.currentVersion, fixture.versions) - - try { - await mounted.component.value.openRoutedPair({ from: 'current:3', to: 'current' }) - await waitFor(() => document.querySelector('.modal-wrapper [role="status"]')) - expect(document.querySelectorAll('.modal-wrapper [role="status"]')).to.have.length(1) - expect(document.querySelector('.modal-wrapper [role="status"]').textContent).to.contain('Select two different versions.') - expect(document.querySelectorAll('.modal-wrapper [role="alert"]')).to.have.length(0) - expect([...document.querySelectorAll('.modal-wrapper button')].some(({ textContent }) => textContent.includes('Retry'))).to.equal(false) - } finally { - mounted.app.unmount() - mounted.element.remove() - } - }) - }) -}) diff --git a/cypress/e2e/page-versions.spec.js b/cypress/e2e/page-versions.spec.js index d9b86a8b52..c20435a8eb 100644 --- a/cypress/e2e/page-versions.spec.js +++ b/cypress/e2e/page-versions.spec.js @@ -3,561 +3,36 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { createVersionComparisonAccount } from '../../playwright/support/helpers/versionComparisonFixtures.ts' -import { createCollectiveShare, deleteShare } from '../../src/apis/collectives/shares.js' -import { listVersions } from '../../src/apis/dav/davRequests.js' - -const HISTORICAL_SNAPSHOT_URL = /\/remote\.php\/dav\/versions\/(?!.*[?&]timestamp=\d{13}(?:&|$))/ -const CURRENT_SNAPSHOT_URL = /\/remote\.php\/dav\/versions\/.*[?&]timestamp=\d{13}(?:&|$)/ -const SEMANTIC_E2E = Cypress.expose('semanticE2E') === true || Cypress.expose('semanticE2E') === '1' -const describeSemantic = SEMANTIC_E2E ? describe : () => {} -const RUN_NAMESPACE = crypto.randomUUID().slice(0, 8) -const fixtureName = (name) => `c599-e2e-${RUN_NAMESPACE}-${name}` -const COLLECTIVE_NAME = fixtureName('versions') -const PAGE_NAME = fixtureName('page') -const FRESH_PAGE_NAME = fixtureName('fresh-page') -const STABLE_PAGE_NAME = fixtureName('stable-link-page') -const REMOVED_VERSION_PAGE_NAME = fixtureName('removed-version-page') -const SINGLE_REMOVED_VERSION_PAGE_NAME = fixtureName('single-removed-version-page') -const VIEWER_FALLBACK_PAGE_NAME = fixtureName('viewer-fallback-page') -const RAPID_COMPARE_PAGE_NAME = fixtureName('rapid-compare-page') -const RAPID_GENERATION_PAGE_NAME = fixtureName('rapid-generation-page') -const RESTORE_AUTH_PAGE_NAME = fixtureName('restore-auth-page') -const OWNER = createVersionComparisonAccount('owner', `cypress-${RUN_NAMESPACE}`, () => crypto.randomUUID()) -const READER = createVersionComparisonAccount('reader', `cypress-${RUN_NAMESPACE}`, () => crypto.randomUUID()) -const READER_USER = READER.userId -const OCS_HEADERS = { 'OCS-APIRequest': 'true' } - -function provisioningApiUrl(userId = '') { - const baseUrl = Cypress.config('baseUrl').replace(/\/index\.php\/?$/, '') - const userPath = userId ? `/${encodeURIComponent(userId)}` : '' - return `${baseUrl}/ocs/v2.php/cloud/users${userPath}` -} - -function provisionTestUser(user) { - return deleteTestUser(user, false).then(() => cy.request({ - method: 'POST', - url: provisioningApiUrl(), - auth: { username: 'admin', password: 'admin' }, - headers: OCS_HEADERS, - body: { - userid: user.userId, - password: user.password, - language: user.language, - }, - form: true, - log: false, - })) -} - -function deleteTestUser(user, failOnStatusCode = true) { - return cy.clearCookies({ log: false }).then(() => cy.request({ - method: 'DELETE', - url: provisioningApiUrl(user.userId), - auth: { username: 'admin', password: 'admin' }, - headers: OCS_HEADERS, - failOnStatusCode, - log: false, - })) -} - -function logoutAndClearSession() { - cy.logout() - return cy.then(() => Cypress.session.clearAllSavedSessions()) -} - -function authenticatedDavRequest(user, options) { - return cy.clearCookies({ log: false }).then(() => cy.request({ - ...options, - auth: { username: user.userId, password: user.password }, - log: false, - })) -} - -function versionEntries(body) { - return (body.match(/[\s\S]*?<\/d:response>/g) ?? []) - .filter((entry) => entry.includes('text/markdown')) - .map((entry) => ({ - href: entry.match(/([^<]+)<\/d:href>/)?.[1], - lastModified: Date.parse(entry.match(/([^<]+)<\/d:getlastmodified>/)?.[1]), - })) - .filter(({ href, lastModified }) => href && Number.isFinite(lastModified)) - .toSorted((first, second) => first.lastModified - second.lastModified) -} - -function listDavVersions(user, url) { - return authenticatedDavRequest(user, { - method: 'PROPFIND', - url, - headers: { Depth: '1', 'Content-Type': 'application/xml' }, - body: listVersions(), - }) -} - -function selectVersionAt(selectorIndex, optionIndex, options = {}) { - cy.get('.version-comparison-dialog select').eq(selectorIndex).then(($select) => { - cy.wrap($select).select($select.find('option').eq(optionIndex).val(), options) - }) -} - -function selectVersionFromEnd(selectorIndex, offset, options = {}) { - cy.get('.version-comparison-dialog select').eq(selectorIndex).then(($select) => { - const optionIndex = $select.find('option').length - offset - cy.wrap($select).select($select.find('option').eq(optionIndex).val(), options) - }) -} - -function getVersionComparisonModal() { - return cy.get('.modal-container').filter(':has(.version-comparison-dialog)') -} - -function appendQuarantinedVersionEntries(body) { - const entries = body.match(/[\s\S]*?<\/d:response>/g) ?? [] - const historicalEntries = entries - .filter((entry) => entry.includes('text/markdown')) - .map((entry) => ({ - entry, - href: entry.match(/([^<]+)<\/d:href>/)?.[1], - lastModified: Date.parse(entry.match(/([^<]+)<\/d:getlastmodified>/)?.[1]), - })) - .filter(({ href, lastModified }) => href && Number.isFinite(lastModified)) - .toSorted((first, second) => first.lastModified - second.lastModified) - const { entry: duplicatedEntry, href } = historicalEntries[0] ?? {} - if (!duplicatedEntry || !href) { - throw new Error('Could not find a historical DAV version to duplicate') - } - const rawVersionId = href.split('/').at(-1) - const duplicateVersionId = decodeURIComponent(rawVersionId) - const encodedFirstCharacter = `%${rawVersionId.charCodeAt(0).toString(16).toUpperCase()}` - const duplicateEntry = duplicatedEntry.replace( - href, - `${href.slice(0, -rawVersionId.length)}${encodedFirstCharacter}${rawVersionId.slice(1)}`, - ) - const reservedEntry = duplicatedEntry.replace( - /([^<]*\/)[^/<]+(<\/d:href>)/, - '$1current$2', - ) - const mutatedBody = body.replace( - /(<\/(?:d:)?multistatus>)/i, - `${duplicateEntry}${reservedEntry}$1`, - ) - if (mutatedBody === body) { - throw new Error('Could not append quarantined DAV versions') - } - return { - body: mutatedBody, - duplicateVersionId, - } -} - -function closeSemanticComparison() { - getVersionComparisonModal() - .parents('.modal-mask') - .should('have.css', 'opacity', '1') - getVersionComparisonModal().find('button.modal-container__close').click() - cy.get('.version-comparison-dialog').should('not.exist') -} - -function openVersionsSidebar() { - cy.get('body').should(($body) => { - expect( - $body.find('#tab-button-versions:visible, button.page-sidebar-button:visible').length, - 'visible Versions tab or sidebar button', - ).to.be.greaterThan(0) - }).then(($body) => { - if (!$body.find('#tab-button-versions:visible').length) { - cy.get('button.page-sidebar-button:visible').click() - } - }) - cy.get('#tab-button-versions').should('be.visible').click() -} - -// The semantic comparison opens with the full documents of both versions -function waitForSemanticComparison() { - cy.get('.version-comparison-dialog .text-comparison__document--after').should('be.visible') -} - -function openInitialCurrentSemanticComparison() { - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(3) - .find('.list-item-content__actions') - .click() - cy.clickMenuButton('Compare with current version') - waitForSemanticComparison() -} - -function insertEditorContent(content, appendParagraph = false) { - cy.getEditorContent(true).should('be.visible') - cy.window({ log: false }).then((window) => { - const components = window.OCA?.Text?.editorComponents - if (!components) { - throw new Error('Text editor components are unavailable') - } - const component = Array.from(components) - .find((candidate) => candidate.active) - if (!component) { - throw new Error('No active Text editor component is available') - } - cy.wrap(component.whenSynced, { log: false }).then(() => { - const command = appendParagraph - ? component.editor.chain().focus('end').insertContent({ - type: 'paragraph', - content: [{ type: 'text', text: content }], - }).run() - : component.editor.commands.insertContent(content) - expect(command, 'editor content command').to.equal(true) - }) - }) -} - -let publicSharePath = '' -let publicShare = null - -function closeViewerComparison() { - cy.window().then((window) => window.OCA.Viewer.close()) - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal, #viewer[role="dialog"]').should('not.exist') -} - -const ROLLBACK_SECTION = `## Rollback ownership - -Maya owns rollback approval. - -Escalate checkout regressions to the release commander.` - -const EVIDENCE_SECTION = `## Audit archive - -Each decision receives a durable timestamp. - -Signed records remain with the project history.` - -const READINESS_SECTION = `## Regional readiness - -All checkout regions use the same health gate. - -Regional owners confirm capacity before launch.` - -const INITIAL_CONTENT = `--- -release: atlas-2.4 -status: draft -owner: Maya Chen ---- - -# Atlas 2.4 release plan - -> Draft rollout window: Thursday, 18 July. - -## Objective - -Ship **Atlas 2.4** to a 10% pilot cohort while protecting checkout availability. - -Release owner: Maya Chen. - -Status cadence: every 15 minutes. - -All named owners have acknowledged the window. - -Status dashboard is available to the release team. - -The dashboard is reviewed before each traffic increase. - -Keep the [incident channel](https://chat.example.test/atlas) staffed during rollout. - -The incident commander acknowledges every escalation. - -Follow the [operator runbook](https://docs.example.test/atlas/draft). - -The release commander validates every operator handoff. - -Customer update is ready for review. - -Checkout remains available throughout the launch. - -${ROLLBACK_SECTION} - -${EVIDENCE_SECTION} - -${READINESS_SECTION} - -## Decision protocol - -The release commander records every traffic decision. - -## Rollout checklist - -- [x] Freeze schema changes -- [ ] Confirm support coverage -- [ ] Enable the pilot cohort - -## Schedule - -| Stage | Owner | Traffic | -| --- | --- | --- | -| Pilot | Maya | 10% | -| General availability | Lee | Pending | - -Checkout remains available throughout the launch. - -::: info -The checkout health gate must remain green for fifteen minutes. -::: - -Health checks are sampled from every checkout region. - -
-Escalation contacts -Maya leads release decisions; Noor leads rollback execution. -
- -## Guardrail - -\`\`\`js -const rolloutPercent = 10 -const rollbackThreshold = 0.02 -\`\`\` - -Rollback if error rate exceeds \`2%\`. - -The rollout decision is recorded in the release evidence.[^atlas-draft] - -Visual evidence follows the signed release decision. - -![Atlas rollout dashboard](/core/img/logo/logo.svg) - -The image checksum is recorded separately. - -[^atlas-draft]: Draft approval requires checkout error rate below two percent. -` - -const SECOND_CONTENT = INITIAL_CONTENT - .replace('# Atlas 2.4 release plan', '# Atlas 2.4 release plan #') - .replace('Status dashboard is available to the release team.', 'Status dashboard is available to the release team. ') - .replaceAll('\n', '\r\n') - .replace(/\r\n$/, '') - -const REVIEWED_CONTENT = INITIAL_CONTENT - .replace('status: draft', 'status: reviewed') - .replace('Draft rollout window', 'Reviewed rollout window') - .replace('10% pilot cohort', '15% reviewed cohort') - .replace('- [ ] Confirm support coverage', '- [x] Confirm support coverage') - .replace('| Pilot | Maya | 10% |', '| Pilot | Maya | 15% |') - .replace('const rolloutPercent = 10', 'const rolloutPercent = 15') - -const CURRENT_CONTENT = `--- -release: atlas-2.4 -status: launch-ready -owner: Maya Chen and Noor Patel ---- - -# Atlas 2.4 launch plan - -> Approved rollout window: Friday, 19 July. - -## Objective - -Ship **Atlas 2.4** through a 25% progressive rollout while protecting checkout availability. - -Release owner: **Maya Chen**. - -Status cadence: *every 15 minutes*. - -All named owners have acknowledged the window. - -[Status dashboard](https://status.example.test/atlas) is available to the release team. - -The dashboard is reviewed before each traffic increase. - -Keep the incident channel staffed during rollout. - -The incident commander acknowledges every escalation. - -Follow the [operator runbook](https://docs.example.test/atlas/2.4). - -The release commander validates every operator handoff. - -Customer **launch update** is ready for publication. - -Checkout remains available throughout the launch. - -${EVIDENCE_SECTION} - -${READINESS_SECTION} - -${ROLLBACK_SECTION} - -## Decision protocol - -The release commander records every traffic decision. - -## Rollout checklist - -- [x] Freeze schema changes -- [x] Confirm support coverage -- [x] Enable the pilot cohort -- [ ] Publish the customer update - -## Schedule - -| Stage | Owner | Traffic | -| --- | --- | --- | -| Canary | Maya | 25% | -| General availability | Lee | 100% | -| Rollback rehearsal | Noor | Complete | - -Checkout remains available throughout the launch. - -::: warn -The checkout health gate must remain green for fifteen minutes. -::: - -Health checks are sampled from every checkout region. - -
-Escalation contacts -Maya leads release decisions; Noor executes rollback and Lee owns customer communication. -
- -## Guardrail - -\`\`\`js -const rolloutPercent = 25 -const rollbackThreshold = 0.015 -\`\`\` - -Rollback if error rate exceeds \`1.5%\` for five minutes. - -The rollout decision is recorded in the release evidence.[^atlas-launch] - -Visual evidence follows the signed release decision. - -![Atlas launch control room](/core/img/logo/logo.svg) - -The image checksum is recorded separately. - -[^atlas-launch]: Launch approval requires checkout error rate below one and a half percent for five minutes. -` - -const INITIAL_PHRASE = '10% pilot cohort' -const REVIEWED_PHRASE = '15% reviewed cohort' -const CURRENT_PHRASE = '25% progressive rollout' -const STABLE_LINK_CURRENT = 'Stable comparison snapshot' -const STABLE_LINK_UPDATED = 'Later page update' -const RAPID_FIRST_PHRASE = 'Rapid comparison first save' -const RAPID_SECOND_PHRASE = 'Rapid comparison second save' - -describeSemantic('Page versions semantic comparison', function() { +describe('Page versions', function() { before(function() { - provisionTestUser(OWNER) - provisionTestUser(READER) - cy.login(OWNER) - cy.deleteAndSeedCollective(COLLECTIVE_NAME) - .seedPage(PAGE_NAME, '', 'Readme.md') - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(FRESH_PAGE_NAME, '', 'Readme.md') - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(STABLE_PAGE_NAME, '', 'Readme.md') - - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(REMOVED_VERSION_PAGE_NAME, '', 'Readme.md') - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(SINGLE_REMOVED_VERSION_PAGE_NAME, '', 'Readme.md') - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(VIEWER_FALLBACK_PAGE_NAME, '', 'Readme.md') - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(RAPID_COMPARE_PAGE_NAME, '', 'Readme.md') - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${RAPID_COMPARE_PAGE_NAME}.md`, RAPID_FIRST_PHRASE) - .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${RAPID_COMPARE_PAGE_NAME}.md`, RAPID_SECOND_PHRASE) - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(RAPID_GENERATION_PAGE_NAME, '', 'Readme.md') - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .seedPage(RESTORE_AUTH_PAGE_NAME, '', 'Readme.md') - cy.seedPageContent(`${COLLECTIVE_NAME}/${RESTORE_AUTH_PAGE_NAME}.md`, INITIAL_CONTENT) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${RESTORE_AUTH_PAGE_NAME}.md`, CURRENT_CONTENT) + cy.loginAs('bob') + cy.deleteAndSeedCollective('Versions Collective') + .seedPage('Page', '', 'Readme.md') // A new version will not be created if the changes occur within less than one second of each other. // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${PAGE_NAME}.md`, INITIAL_CONTENT) - .wait(1100) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${PAGE_NAME}.md`, SECOND_CONTENT) - .wait(1100) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${PAGE_NAME}.md`, REVIEWED_CONTENT) - .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${PAGE_NAME}.md`, CURRENT_CONTENT) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${STABLE_PAGE_NAME}.md`, 'Stable comparison baseline') - .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${STABLE_PAGE_NAME}.md`, STABLE_LINK_CURRENT) - - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${REMOVED_VERSION_PAGE_NAME}.md`, INITIAL_CONTENT) - .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${REMOVED_VERSION_PAGE_NAME}.md`, CURRENT_CONTENT) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${SINGLE_REMOVED_VERSION_PAGE_NAME}.md`, INITIAL_CONTENT) + cy.seedPageContent('Versions Collective/Page.md', 'v1') .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${SINGLE_REMOVED_VERSION_PAGE_NAME}.md`, CURRENT_CONTENT) // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${VIEWER_FALLBACK_PAGE_NAME}.md`, INITIAL_CONTENT) + cy.seedPageContent('Versions Collective/Page.md', 'v2') .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${VIEWER_FALLBACK_PAGE_NAME}.md`, CURRENT_CONTENT) // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${RAPID_GENERATION_PAGE_NAME}.md`, INITIAL_CONTENT) + cy.seedPageContent('Versions Collective/Page.md', 'v3') .wait(1100) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${RAPID_GENERATION_PAGE_NAME}.md`, REVIEWED_CONTENT) - .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${RAPID_GENERATION_PAGE_NAME}.md`, CURRENT_CONTENT) - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .then(({ circleId }) => cy.wrap({ id: circleId }) - .circleAddMember(READER_USER) - .circleSetMemberLevel(4)) - cy.seedCollectivePermissions(COLLECTIVE_NAME, 'edit', 8) - cy.getCollectives() - .findBy({ name: COLLECTIVE_NAME }) - .then(({ id }) => createCollectiveShare(id).then(({ data }) => { - const { token } = data.ocs.data - publicShare = { collectiveId: id, pageId: 0, token } - publicSharePath = `/apps/collectives/p/${token}/${COLLECTIVE_NAME}/${PAGE_NAME}` - })) - }) - - after(function() { - cy.login(OWNER) - cy.then(() => publicShare && deleteShare(publicShare)) - cy.deleteCollective(COLLECTIVE_NAME) - deleteTestUser(READER) - deleteTestUser(OWNER) + cy.seedPageContent('Versions Collective/Page.md', 'v4') }) beforeEach(function() { - cy.login(OWNER) - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}`) - cy.window().should((window) => { - expect(typeof window.OCA?.Text?.createMarkdownContentComparison, 'Text semantic comparison factory is available') - .to.equal('function') - }) + cy.loginAs('bob') + cy.visit('/apps/collectives/Versions Collective/Page') - openVersionsSidebar() + cy.get('button.page-sidebar-button').click() + cy.get('#tab-button-versions').click() }) it('Lists versions', function() { cy.getReadOnlyEditor() - .should('contain', CURRENT_PHRASE) + .should('contain', 'v4') cy.get('.app-sidebar-tabs__content .version-list .list-item') .should('have.length', 4) @@ -569,23 +44,6 @@ describeSemantic('Page versions semantic comparison', function() { .should('contain', 'Initial version') }) - it('Hides comparison when no historical version exists', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${FRESH_PAGE_NAME}`) - cy.get('#tab-button-versions').click() - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('have.length', 1) - cy.contains('button', 'Compare versions…').should('not.exist') - }) - - it('distinguishes every version selector option down to the second', function() { - cy.contains('button', 'Compare versions…').click() - cy.get('.version-comparison-dialog select').each(($select) => { - const labels = [...$select[0].options].map(({ text }) => text.trim()) - expect(new Set(labels).size).to.equal(labels.length) - expect(labels.every((label) => /\d{1,2}:\d{2}:\d{2}/.test(label))).to.equal(true) - }) - }) - it('Open initial and current version', function() { cy.get('.app-sidebar-tabs__content .version-list .list-item') .contains('Initial version') @@ -595,7 +53,7 @@ describeSemantic('Page versions semantic comparison', function() { .find('.title-version') .should('be.visible') cy.getReadOnlyEditor() - .should('contain', INITIAL_PHRASE) + .should('contain', 'v1') cy.get('.app-sidebar-tabs__content .version-list .list-item') .contains('Current version') @@ -605,7 +63,7 @@ describeSemantic('Page versions semantic comparison', function() { .find('.title-version') .should('not.exist') cy.getReadOnlyEditor() - .should('contain', CURRENT_PHRASE) + .should('contain', 'v4') }) it('Add label to version', function() { @@ -623,935 +81,25 @@ describeSemantic('Page versions semantic comparison', function() { .should('contain', 'v3') }) - it('C01 compares a historical snapshot before the current snapshot', function() { - const unexpectedFailures = [] - cy.window().then((window) => { - window.addEventListener('error', ({ message }) => { - if (!message.includes('ResizeObserver')) { - unexpectedFailures.push(`page error: ${message}`) - } - }) - window.addEventListener('unhandledrejection', ({ reason }) => unexpectedFailures.push(`unhandled rejection: ${String(reason)}`)) - cy.stub(window.console, 'error').callsFake((...args) => unexpectedFailures.push(`console error: ${args.join(' ')}`)) - }) - const recordFailedResponse = (request) => request.continue((response) => { - response.headers['cache-control'] = 'no-store' - if (response.statusCode >= 400) { - unexpectedFailures.push(`${response.statusCode} ${request.url}`) - } - }) - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, recordFailedResponse) - .as('historicalSnapshotRequest') - cy.intercept('GET', CURRENT_SNAPSHOT_URL, recordFailedResponse) - .as('currentSnapshotRequest') - cy.get('.search-dialog-container').should('not.exist') - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(3) - .find('.list-item-content__actions') - .click() - - cy.clickMenuButton('Compare with current version') - - getVersionComparisonModal() - .parent() - .should('have.class', 'modal-wrapper--full') - getVersionComparisonModal().find('button[type="submit"]').should('not.exist') - getVersionComparisonModal().contains('button', 'Copy comparison link').should('be.visible') - cy.get('.version-comparison-dialog') - .should('have.css', 'display', 'flex') - .and('have.css', 'overflow', 'hidden') - cy.get('.version-comparison-dialog__comparison') - .should('have.css', 'display', 'flex') - .and('have.css', 'overflow', 'hidden') - .then(($host) => { - const host = $host[0].getBoundingClientRect() - const content = $host[0].parentElement.getBoundingClientRect() - expect(Math.abs(host.bottom - content.bottom), 'host fills remaining dialog height') - .to.be.lessThan(2) - }) - cy.get('.version-comparison-dialog__selectors .text-comparison').should('not.exist') - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents') - .should('have.attr', 'aria-selected', 'true') - cy.contains('.version-comparison-dialog [role="tab"]', 'Changes') - .click() - cy.get('.version-comparison-dialog .text-comparison__changes-list') - .should('be.visible') - .and('not.contain', 'Unknown change') - cy.get('.version-comparison-dialog [data-comparison-select]') - .should(($records) => { - expect($records.length).to.be.greaterThan(10) - }) - cy.get('.version-comparison-dialog [data-comparison-select]').each(($record) => { - expect($record.attr('aria-label'), 'accessible change summary').to.match(/\S/) - expect($record.text().trim(), 'visible change summary').not.to.be.empty - }) - cy.get('.version-comparison-dialog .text-comparison__changes-list [data-comparison-select]') - .its('length') - .then((unfilteredCount) => { - cy.contains('.version-comparison-dialog label', 'Hide formatting-only changes') - .find('input[type="checkbox"]') - .check() - cy.get('.version-comparison-dialog .text-comparison__changes-list [data-comparison-select]') - .should('have.length.lessThan', unfilteredCount) - cy.contains('.version-comparison-dialog label', 'Hide formatting-only changes') - .find('input[type="checkbox"]') - .uncheck() - }) - cy.get('.version-comparison-dialog [data-comparison-select]').first() - .then(($record) => { - const changeId = $record.attr('data-comparison-select') - expect(changeId).to.be.a('string').and.not.be.empty - cy.wrap($record).click() - cy.wrap($record).should('have.attr', 'aria-current', 'true') - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents') - .should('have.attr', 'aria-selected', 'true') - cy.get('.version-comparison-dialog .text-comparison__document--before [data-comparison-change].text-comparison-change--current') - .should('have.class', 'text-comparison-change--current') - cy.get('.version-comparison-dialog .text-comparison__document--after [data-comparison-change].text-comparison-change--current') - .should('have.class', 'text-comparison-change--current') - }) - cy.contains('.version-comparison-dialog [role="tab"]', 'Changes') - .click() - cy.contains('.version-comparison-dialog [role="tab"]', 'Changes') - .should('have.attr', 'aria-selected', 'true') - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents') - .click() - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents') - .should('have.attr', 'aria-selected', 'true') - cy.get('.version-comparison-dialog .text-comparison__document-scroller') - .should('have.length', 2) - cy.get('.version-comparison-dialog .text-comparison__document--before') - .should('contain', INITIAL_PHRASE) - .and('contain', 'Audit archive') - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', CURRENT_PHRASE) - .and('contain', 'Audit archive') - cy.get('.version-comparison-dialog .ProseMirror') - .should('have.length', 2) - .find('table') - .should('have.length', 2) - cy.get('.version-comparison-dialog .ProseMirror').should(($documents) => { - const placeholderSelector = '.text-comparison-change--empty, [data-comparison-empty], [data-comparison-placeholder]' - expect($documents.find(placeholderSelector), 'synthetic empty-side nodes').to.have.length(0) - const structuralParents = $documents.find('tr, tbody, table, ul, ol, li, p, span') - expect( - [...structuralParents].some((element) => element.textContent?.includes('•')), - 'synthetic counterpart bullets in structural or inline parents', - ).to.equal(false) - }) - cy.get('.version-comparison-dialog .text-comparison__document figure[data-component="image-view"][data-attachment-type="image"]') - .should('have.length', 2) - cy.get('@historicalSnapshotRequest.all') - .should('have.length', 1) - .its('0.request.url') - .should('not.contain', 'timestamp=') - cy.get('@currentSnapshotRequest.all').should('have.length', 1) - closeSemanticComparison() - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(3) - .find('.list-item-content__actions') - .click() - cy.clickMenuButton('Compare with current version') - waitForSemanticComparison() - cy.get('@historicalSnapshotRequest.all').should('have.length', 2) - cy.get('@currentSnapshotRequest.all').should('have.length', 2) - closeSemanticComparison() - cy.get('.search-dialog-container').should('not.exist') - cy.get('#tab-button-attachments').click() - cy.get('.app-sidebar-tabs__content').should('contain', 'No attachments') - cy.then(() => expect(JSON.stringify(unexpectedFailures), 'unexplained comparison failures').to.equal('[]')) - }) - - it('C05 reuses a successful historical snapshot while the dialog remains open', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('cachedHistoricalSnapshot') - openInitialCurrentSemanticComparison() - cy.get('@cachedHistoricalSnapshot.all').should('have.length', 1) - - selectVersionAt(0, 2) - getVersionComparisonModal().find('button[type="submit"]').click() - waitForSemanticComparison() - cy.get('@cachedHistoricalSnapshot.all').should('have.length', 2) - - selectVersionAt(0, 3) - getVersionComparisonModal().find('button[type="submit"]').click() - waitForSemanticComparison() - cy.get('@cachedHistoricalSnapshot.all').should('have.length', 2) - }) - - it('C06 reloads the current snapshot for every comparison attempt', function() { - cy.intercept('GET', CURRENT_SNAPSHOT_URL).as('uncachedCurrentSnapshot') - openInitialCurrentSemanticComparison() - cy.get('@uncachedCurrentSnapshot.all').should('have.length', 1) - - selectVersionAt(0, 2) - getVersionComparisonModal().find('button[type="submit"]').click() - waitForSemanticComparison() - cy.get('@uncachedCurrentSnapshot.all').should('have.length', 2) - - selectVersionAt(0, 3) - getVersionComparisonModal().find('button[type="submit"]').click() - waitForSemanticComparison() - cy.get('@uncachedCurrentSnapshot.all').should('have.length', 3) - }) - - it('F11 completes comparison with no unexplained browser or snapshot failures', function() { - const unexpectedFailures = [] - cy.window().then((window) => { - window.addEventListener('error', ({ message }) => { - if (!message.includes('ResizeObserver')) { - unexpectedFailures.push(`page error: ${message}`) - } - }) - window.addEventListener('unhandledrejection', ({ reason }) => unexpectedFailures.push(`unhandled rejection: ${String(reason)}`)) - cy.stub(window.console, 'error').callsFake((...args) => unexpectedFailures.push(`console error: ${args.join(' ')}`)) - }) - const recordFailedResponse = (request) => request.continue((response) => { - if (response.statusCode >= 400) { - unexpectedFailures.push(`${response.statusCode} ${request.url}`) - } - }) - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, recordFailedResponse) - cy.intercept('GET', CURRENT_SNAPSHOT_URL, recordFailedResponse) - + it('Compare initial and current version', function() { cy.get('.app-sidebar-tabs__content .version-list .list-item') .eq(3) .find('.list-item-content__actions') .click() - cy.clickMenuButton('Compare with current version') - waitForSemanticComparison() - closeSemanticComparison() - cy.then(() => expect(JSON.stringify(unexpectedFailures), 'unexplained comparison failures').to.equal('[]')) - }) - - it('C02 loads two immutable historical snapshots', function() { - cy.contains('button', 'Compare versions…').click() - selectVersionFromEnd(0, 1) - selectVersionFromEnd(1, 3) - getVersionComparisonModal().find('button[type="submit"]').click() - - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents').click() - cy.get('.version-comparison-dialog .text-comparison__document--before') - .should('contain', INITIAL_PHRASE) - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', REVIEWED_PHRASE) - }) - it('C03 normalizes reversed selectors without swapping visible labels', function() { - let expectedEarlierLabel - let expectedLaterLabel - cy.contains('button', 'Compare versions…').click() - selectVersionFromEnd(0, 3) - selectVersionFromEnd(1, 1) - cy.get('.version-comparison-dialog select').eq(0).find('option:selected').invoke('text') - .then((label) => { expectedLaterLabel = label.trim() }) - cy.get('.version-comparison-dialog select').eq(1).find('option:selected').invoke('text') - .then((label) => { expectedEarlierLabel = label.trim() }) - getVersionComparisonModal().find('button[type="submit"]').click() - - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents').click() - cy.get('.version-comparison-dialog .text-comparison__document--before') - .should('contain', INITIAL_PHRASE) - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', REVIEWED_PHRASE) - cy.get('.version-comparison-dialog select').eq(0).should(($select) => { - expect($select.val()).to.match(/^version:[^/\\]+$/) - expect($select.find('option:selected').text().trim()).to.equal(expectedEarlierLabel) - }) - cy.get('.version-comparison-dialog select').eq(1).should(($select) => { - expect($select.find('option:selected').text().trim()).to.equal(expectedLaterLabel) - }) - }) - - it('shows syntax-only Markdown differences without inventing rendered changes', function() { - cy.contains('button', 'Compare versions…').click() - selectVersionFromEnd(0, 1) - selectVersionFromEnd(1, 2) - getVersionComparisonModal().find('button[type="submit"]').click() - - cy.get('.version-comparison-dialog .text-comparison') - .should('contain', 'No rendered differences — Markdown syntax differs.') - .and('not.contain', 'Moved section') - closeSemanticComparison() - }) - - it('R01 encodes the exact ordered pair in a canonical comparison route', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?view=grid#rollout`) - openVersionsSidebar() - openInitialCurrentSemanticComparison() - - cy.get('.version-comparison-dialog select').then(($selects) => { - cy.location().should((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.equal($selects.eq(0).val()) - expect(query.get('compareTo')).to.match(/^current:\d+$/) - expect(query.get('view')).to.equal('grid') - expect(location.hash).to.equal('#rollout') - expect(location.href).not.to.contain('/remote.php/dav') - }) - }) - }) - - it('R02 reload restores the exact comparison pair and view', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?view=grid#rollout`) - openVersionsSidebar() - openInitialCurrentSemanticComparison() - cy.location('href').as('reloadedComparisonUrl') - - cy.reload() - - waitForSemanticComparison() - cy.get('@reloadedComparisonUrl').then((comparisonUrl) => { - cy.location('href').should('eq', comparisonUrl) - }) - }) - - it('R03 Back closes the managed comparison and restores the prior route', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?view=grid#rollout`) - openVersionsSidebar() - openInitialCurrentSemanticComparison() - - cy.go('back') - - cy.get('.version-comparison-dialog').should('not.exist') - cy.location().should((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.be.null - expect(query.get('compareTo')).to.be.null - expect(query.get('view')).to.equal('grid') - expect(location.hash).to.equal('#rollout') - }) - }) - - it('R05 copied link opens the same pair in a fresh authenticated session', function() { - cy.stubClipboardAndVisit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?view=grid#rollout`) - openVersionsSidebar() - openInitialCurrentSemanticComparison() - cy.location('href').as('freshSessionComparisonUrl') - getVersionComparisonModal().contains('button', 'Copy comparison link').click() - cy.get('@freshSessionComparisonUrl').then((comparisonUrl) => { - cy.getClipboardText().should('eq', comparisonUrl) - cy.clearCookies() - cy.clearLocalStorage() - cy.login(OWNER) - cy.visit(comparisonUrl) - }) - - waitForSemanticComparison() - cy.get('@freshSessionComparisonUrl').then((comparisonUrl) => { - cy.location('href').should('eq', comparisonUrl) - }) - }) - - it('R10 closing a managed route preserves unrelated query and history state', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?view=grid#rollout`) - openVersionsSidebar() - openInitialCurrentSemanticComparison() - - closeSemanticComparison() - - cy.location().should((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.be.null - expect(query.get('compareTo')).to.be.null - expect(query.get('view')).to.equal('grid') - expect(location.hash).to.equal('#rollout') - }) - cy.window().should(({ history }) => { - expect(history.state?.collectivesVersionComparison).not.to.equal(true) - }) - }) - - it('R04 Forward reopens the exact semantic comparison state', function() { - cy.stubClipboardAndVisit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?view=grid#rollout`) - openVersionsSidebar() - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(3) - .find('.list-item-content__actions') - .click() - cy.clickMenuButton('Compare with current version') - - waitForSemanticComparison() - cy.location().then((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.match(/^version:[^/\\]+$/) - expect(query.get('compareTo')).to.match(/^current:\d+$/) - expect(query.get('view')).to.equal('grid') - expect(location.hash).to.equal('#rollout') - expect(location.href).not.to.contain('/remote.php/dav') - }) - cy.location('href').as('comparisonUrl') - cy.go('back') - cy.get('.version-comparison-dialog').should('not.exist') - cy.location('search').should('eq', '?view=grid') - cy.go('forward') - waitForSemanticComparison() - getVersionComparisonModal() - .contains('button', 'Copy comparison link') - .should('not.be.disabled') - .click() - cy.get('@comparisonUrl').then((comparisonUrl) => { - cy.getClipboardText().should('eq', comparisonUrl) - }) - cy.contains('[role="status"]', 'Comparison link copied') - .find('button[aria-label="Close"]') - .click() - cy.get('@clipboardWriteText').then((writeText) => { - writeText.rejects(new Error('clipboard denied')) - }) - getVersionComparisonModal().contains('button', 'Copy comparison link').click() - cy.contains('[role="alert"]', 'Could not copy the comparison link.') - .should('be.visible') - .find('button[aria-label="Close"]') - .click() - cy.reload() - waitForSemanticComparison() - cy.get('@comparisonUrl').then((comparisonUrl) => { - cy.location('href').should('eq', comparisonUrl) - }) - - closeSemanticComparison() - cy.location('search').should('eq', '?view=grid') - cy.go('forward') - waitForSemanticComparison() - selectVersionAt(0, 2) - cy.location('search').should('eq', '?view=grid') - getVersionComparisonModal().should('be.visible') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - closeSemanticComparison() - cy.window().should(({ history }) => { - expect(history.state?.collectivesVersionComparison).not.to.equal(true) - }) - - cy.intercept('GET', CURRENT_SNAPSHOT_URL).as('routedCurrentSnapshotRequest') - cy.get('@comparisonUrl').then((comparisonUrl) => cy.stubClipboardAndVisit(comparisonUrl)) - waitForSemanticComparison() - cy.get('@routedCurrentSnapshotRequest.all').should('have.length', 1) - closeSemanticComparison() - cy.location().should((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.be.null - expect(query.get('compareTo')).to.be.null - expect(query.get('view')).to.equal('grid') - expect(location.hash).to.equal('#rollout') - }) - }) - - it('keeps a copied Current comparison stable after a later page edit', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${STABLE_PAGE_NAME}`) - openVersionsSidebar() - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(1) - .find('.list-item-content__actions') - .click() cy.clickMenuButton('Compare with current version') - waitForSemanticComparison() - - cy.location().then((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.match(/^version:[^/\\]+$/) - expect(query.get('compareTo')).to.match(/^current:\d+$/) - }) - cy.location('href').as('stableComparisonUrl') - closeSemanticComparison() - cy.switchToEditMode() - cy.getEditorContent(true).type(`{selectall}${STABLE_LINK_UPDATED}`) - cy.switchToPreviewMode() - cy.getReadOnlyEditor().should('contain', STABLE_LINK_UPDATED) - cy.get('@stableComparisonUrl').then((comparisonUrl) => cy.visit(comparisonUrl)) - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents').click() - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', STABLE_LINK_CURRENT) - .and('not.contain', STABLE_LINK_UPDATED) - }) - - it('R07 keeps an unavailable routed identity visible without requesting a snapshot', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('historicalSnapshotRequest') - cy.intercept('GET', CURRENT_SNAPSHOT_URL).as('currentSnapshotRequest') - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?compareFrom=missing-version&compareTo=current`) - getVersionComparisonModal() - .should('contain', 'Unavailable version (missing-version)') - .and('contain', 'One of the selected versions has expired or was removed.') - cy.get('@historicalSnapshotRequest.all').should('have.length', 0) - cy.get('@currentSnapshotRequest.all').should('have.length', 0) - getVersionComparisonModal().contains('button', 'Retry').should('be.visible') - closeSemanticComparison() - cy.location('search').should('eq', '') - }) - - it('R08 keeps two unavailable routed identities visible without requesting snapshots', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('historicalSnapshotRequest') - cy.intercept('GET', CURRENT_SNAPSHOT_URL).as('currentSnapshotRequest') - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?compareFrom=missing-one&compareTo=missing-two`) - getVersionComparisonModal() - .should('contain', 'Unavailable version (missing-one)') - .and('contain', 'Unavailable version (missing-two)') - .and('contain', 'The selected versions have expired or were removed.') - cy.get('@historicalSnapshotRequest.all').should('have.length', 0) - cy.get('@currentSnapshotRequest.all').should('have.length', 0) - }) - it('R09 quarantines ambiguous DAV identities without disabling valid versions', function() { - let duplicateVersionId - let expectedOptionCount - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .its('length') - .then((count) => { expectedOptionCount = count }) - cy.intercept('PROPFIND', '**/remote.php/dav/versions/**', (request) => { - request.continue((response) => { - const mutated = appendQuarantinedVersionEntries(response.body) - duplicateVersionId = mutated.duplicateVersionId - response.body = mutated.body - }) - }).as('versionsWithQuarantinedEntries') - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}`) - openVersionsSidebar() - cy.wait('@versionsWithQuarantinedEntries') - cy.then(() => { - cy.get('.app-sidebar-tabs__content .version-list .version') - .filter((_index, element) => element.dataset.versionId === String(duplicateVersionId)) - .should('have.length', 2) - .first() - .find('.list-item-content__actions') - .click() - cy.clickMenuButton('Compare with current version') - cy.contains('[role="alert"]', 'This page version cannot be used for comparison.') - .should('be.visible') - .find('button[aria-label="Close"]') - .click() - cy.get('.version-comparison-dialog').should('not.exist') - }) - - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal() - .find('[role="status"]') - .filter(':contains("Some page versions could not be used for comparison.")') - .should('have.length', 1) - cy.get('.version-comparison-dialog select').each(($select) => { - cy.wrap($select).find('option').should('have.length', expectedOptionCount) - cy.wrap($select).find('option[value="version:current"]').should('have.length', 1) - }) - getVersionComparisonModal().find('button[type="submit"]').click() - cy.get('.version-comparison-dialog .text-comparison').should('be.visible') - closeSemanticComparison() - - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('historicalSnapshotRequest') - cy.intercept('GET', CURRENT_SNAPSHOT_URL).as('currentSnapshotRequest') - cy.then(() => { - expect(duplicateVersionId).to.be.a('string').and.not.be.empty - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?compareFrom=${encodeURIComponent(`version:${duplicateVersionId}`)}&compareTo=current`) - }) - getVersionComparisonModal() - .should('contain', 'Ambiguous version') - .and('contain', 'The version comparison link is ambiguous and could not be opened.') - .and('not.contain', duplicateVersionId) - cy.get('@historicalSnapshotRequest.all').should('have.length', 0) - cy.get('@currentSnapshotRequest.all').should('have.length', 0) - }) - - it('R09 rejects malformed pair parameters before requesting versions', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('versionRequest') - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}?compareFrom=versions%2F1&compareTo=current&view=grid#rollout`) - cy.location().should((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.be.null - expect(query.get('compareTo')).to.be.null - expect(query.get('view')).to.equal('grid') - expect(location.hash).to.equal('#rollout') - }) - cy.get('.version-comparison-dialog').should('not.exist') - cy.get('@versionRequest.all').should('have.length', 0) - }) - - it('C16 does not expose version comparison on public routes', function() { - logoutAndClearSession() - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('versionRequest') - cy.visit(`${publicSharePath}?compareFrom=missing-version&compareTo=current&view=grid#rollout`) - cy.getReadOnlyEditor().should('contain', CURRENT_PHRASE) - cy.location().should((location) => { - const query = new URLSearchParams(location.search) - expect(query.get('compareFrom')).to.be.null - expect(query.get('compareTo')).to.be.null - expect(query.get('view')).to.equal('grid') - expect(location.hash).to.equal('#rollout') - }) - cy.contains('[role="status"]', 'Version comparison is not available for public links.').should('be.visible') - cy.get('#tab-button-versions').should('not.exist') - cy.get('@versionRequest.all').should('have.length', 0) - }) - - it('F10 denies a direct anonymous historical DAV snapshot read', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('authorizedSnapshotRead') - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(3) - .find('.list-item-content__actions') - .click() - cy.clickMenuButton('Compare with current version') - cy.wait('@authorizedSnapshotRead').its('request.url').then((snapshotUrl) => { - closeSemanticComparison() - logoutAndClearSession() - cy.request({ - url: snapshotUrl, - failOnStatusCode: false, - followRedirect: false, - }).its('status').should('be.oneOf', [401, 403]) - }) - }) - - it('C04 Blocks comparing a version with itself', function() { - cy.contains('button', 'Compare versions…').click() - selectVersionAt(0, 1) - selectVersionAt(1, 1) - cy.get('.version-comparison-dialog') - .should('contain', 'Select two different versions.') - getVersionComparisonModal().find('button[type="submit"]') - .should('be.disabled') - }) - - it('AUD-06 uses a callable semantic factory despite an unexpected Text API version', function() { - cy.window().then((window) => { - window.OCA.Text.apiVersion = 'unexpected' - }) - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.get('.version-comparison-dialog .text-comparison').should('be.visible') - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents').should('be.visible') - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal, #viewer[role="dialog"]').should('not.exist') - }) - - it('X03 falls back to Viewer when the advertised semantic factory is missing', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('viewerSnapshotRequest') - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${VIEWER_FALLBACK_PAGE_NAME}`) - cy.switchToEditMode() - cy.intercept('POST', '**/apps/text/session/*/save').as('viewerPreparationSave') - const typedBytes = 'No-wait Viewer fallback bytes 7f56c599' - insertEditorContent(typedBytes, true) - openVersionsSidebar() - cy.window().then((window) => { - window.OCA.Text.apiVersion = '1.5' - cy.stub(window.OCA.Text, 'createMarkdownContentComparison').value(undefined).as('missingSemanticFactory') - }) - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@viewerPreparationSave') - cy.get('.version-comparison-dialog').should('not.exist') // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal .viewer__comparison > *:visible, #viewer .viewer--split > .viewer__file-wrapper:visible') - .should('have.length', 2) - .eq(1) - .should('contain', typedBytes) - cy.get('@viewerSnapshotRequest.all').should('have.length', 1) - closeViewerComparison() - cy.get('@missingSemanticFactory').then((stub) => stub.restore()) - }) - - it('prepares current bytes before Viewer fallback dispatch', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${VIEWER_FALLBACK_PAGE_NAME}`) - cy.switchToEditMode() - cy.intercept('POST', '**/apps/text/session/*/save', { statusCode: 500 }).as('failedViewerPreparationSave') - insertEditorContent('Viewer preparation must fail closed 7f56c599', true) - openVersionsSidebar() - cy.window().then((window) => { - cy.stub(window.OCA.Text, 'createMarkdownContentComparison').value(undefined).as('missingSemanticFactory') - }) - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@failedViewerPreparationSave') - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal, #viewer[role="dialog"]').should('not.exist') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'Could not save current changes before comparison. Please try again.') - cy.get('@missingSemanticFactory').then((stub) => stub.restore()) - }) - - it('C11 retry clears a removed-version error and publishes fresh comparison state', function() { - cy.intercept({ - method: 'GET', - times: 1, - url: HISTORICAL_SNAPSHOT_URL, - }, { statusCode: 404 }).as('removedVersion') - - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${REMOVED_VERSION_PAGE_NAME}`) - openVersionsSidebar() - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@removedVersion') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'One of the selected versions has expired or was removed.') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - - getVersionComparisonModal().contains('button', 'Retry').click() - waitForSemanticComparison() - }) - - it('C13 reports one removed version for a single 404 snapshot response', function() { - cy.intercept({ - method: 'GET', - times: 1, - url: HISTORICAL_SNAPSHOT_URL, - }, { statusCode: 404 }).as('singleRemovedVersion') - - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${SINGLE_REMOVED_VERSION_PAGE_NAME}`) - openVersionsSidebar() - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@singleRemovedVersion') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'One of the selected versions has expired or was removed.') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - }) - - it('C12 Shows a permission error when both snapshots are unavailable', function() { - cy.get('body').then(($body) => { - if ($body.find('.version-comparison-dialog').length > 0) { - closeSemanticComparison() - } - }) - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, { statusCode: 403 }) - .as('deniedSnapshots') - - cy.contains('button', 'Compare versions…').click() - selectVersionAt(0, 1) - selectVersionAt(1, 3) - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@deniedSnapshots') - cy.wait('@deniedSnapshots') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'You do not have permission to load the selected versions.') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal, #viewer[role="dialog"]').should('not.exist') - }) - - it('C12 Shows a permission error when one selected snapshot is unavailable', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, { statusCode: 403 }).as('deniedSnapshot') - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@deniedSnapshot') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'You do not have permission to load one of the selected versions.') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - }) - - it('C13 Shows a two-version expiry error for 404 and 410 responses', function() { - let requestCount = 0 - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, (request) => { - request.reply({ statusCode: requestCount++ === 0 ? 410 : 404 }) - }).as('expiredSnapshots') - cy.contains('button', 'Compare versions…').click() - selectVersionAt(0, 1) - selectVersionAt(1, 3) - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@expiredSnapshots') - cy.wait('@expiredSnapshots') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'The selected versions have expired or were removed.') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - }) + cy.get('.viewer__modal, #viewer') + .should('contain', 'v1') + .and('contain', 'v4') - it('C14 reports a network failure instead of treating it as cancellation', function() { - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, (request) => { - request.destroy() - }).as('networkFailure') - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@networkFailure') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'Could not load the selected versions because of a network error.') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') + cy.window().then((window) => window.OCA.Viewer.close()) // Second locator can be removed once we only support Nextcloud 36+ cy.get('.viewer__modal, #viewer[role="dialog"]').should('not.exist') }) - it('C09 cancels a superseded request and prevents stale publication', function() { - const delayedSnapshot = Promise.withResolvers() - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, (request) => { - return delayedSnapshot.promise.then(() => request.reply('Delayed snapshot')) - }).as('delayedSnapshot') - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.get('.version-comparison-dialog__loading').should('be.visible') - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${FRESH_PAGE_NAME}`) - cy.location('pathname').should('contain', `/${FRESH_PAGE_NAME}`) - cy.getReadOnlyEditor().should('be.visible') - cy.then(() => delayedSnapshot.resolve()) - cy.wait('@delayedSnapshot') - cy.get('.version-comparison-dialog').should('not.exist') - cy.get('.text-comparison').should('not.exist') - cy.get('[role="alert"]').should('not.exist') - }) - - it('C10 publishes only the latest generation after rapid pair changes', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${RAPID_GENERATION_PAGE_NAME}`) - openVersionsSidebar() - const delayedSnapshot = Promise.withResolvers() - let delayedFirstSnapshot = false - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL, (request) => { - if (!delayedFirstSnapshot) { - delayedFirstSnapshot = true - request.alias = 'supersededSnapshot' - request.continue(() => delayedSnapshot.promise) - } - }).as('rapidHistoricalSnapshots') - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.get('.version-comparison-dialog__loading').should('be.visible') - selectVersionFromEnd(0, 1, { force: true }) - selectVersionFromEnd(1, 2, { force: true }) - getVersionComparisonModal().find('button[type="submit"]').click() - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents').click() - cy.get('.version-comparison-dialog .text-comparison__document--before') - .should('contain', INITIAL_PHRASE) - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', REVIEWED_PHRASE) - cy.then(() => delayedSnapshot.resolve()) - cy.wait('@supersededSnapshot') - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', REVIEWED_PHRASE) - .and('not.contain', CURRENT_PHRASE) - }) - - it('Reports semantic comparison initialization failures', function() { - cy.window().then((window) => { - cy.stub(window.OCA.Text, 'createMarkdownContentComparison') - .rejects(new Error('comparison failed')) - }) - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'Could not initialize version comparison.') - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - }) - - it('C15 Compares versions for a read-only member', function() { - cy.login(READER) - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}`) - cy.get('button.titleform-button').should('not.exist') - cy.getReadOnlyEditor().should('contain', CURRENT_PHRASE) - cy.get('button.page-sidebar-button').click() - cy.get('#tab-button-versions').click() - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .contains('Initial version') - .closest('.list-item') - .find('.list-item-content__actions') - .click() - cy.contains('button', 'Name this version').should('not.exist') - cy.contains('button', 'Restore version').should('not.exist') - cy.contains('button', 'Delete version').should('not.exist') - cy.clickMenuButton('Compare with current version') - - waitForSemanticComparison() - }) - - it('denies a crafted reader restore and allows the equivalent owner restore', function() { - cy.login(READER) - cy.intercept('PROPFIND', '**/remote.php/dav/versions/**').as('readerVersions') - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${RESTORE_AUTH_PAGE_NAME}`) - cy.getReadOnlyEditor().should('contain', CURRENT_PHRASE) - openVersionsSidebar() - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - waitForSemanticComparison() - - cy.wait('@readerVersions').then(({ request, response }) => { - const beforeEntries = versionEntries(response.body) - expect(beforeEntries.length, 'reader-visible version snapshots').to.be.greaterThan(1) - const sourceUrl = new URL(beforeEntries[0].href, request.url).href - const fileId = new URL(sourceUrl).pathname.split('/').at(-2) - expect(fileId, 'versioned file id').to.not.be.empty - const collectionUrl = request.url - const pageUrl = `${Cypress.expose('baseUrl')}/remote.php/webdav/.Collectives/${encodeURIComponent(COLLECTIVE_NAME)}/${encodeURIComponent(RESTORE_AUTH_PAGE_NAME)}.md` - const destination = `${Cypress.expose('baseUrl')}/remote.php/dav/versions/${encodeURIComponent(READER.userId)}/restore/target` - - return authenticatedDavRequest(READER, { url: pageUrl }).then(({ body: beforeBytes }) => { - return authenticatedDavRequest(READER, { - method: 'MOVE', - url: sourceUrl, - headers: { Destination: destination }, - failOnStatusCode: false, - }).then(({ status, body }) => { - if (status === 500) { - expect(body).to.contain('OCP\\Files\\NotPermittedException') - expect(body).to.contain('Failed to restore version') - } else { - expect(status).to.equal(403) - } - return authenticatedDavRequest(READER, { url: pageUrl }) - }).then(({ body }) => { - expect(body).to.equal(beforeBytes) - return listDavVersions(READER, collectionUrl) - }).then(({ body }) => { - expect(versionEntries(body)).to.deep.equal(beforeEntries) - return { fileId, pageUrl } - }) - }) - }).then(({ fileId, pageUrl }) => { - const ownerCollectionUrl = `${Cypress.expose('baseUrl')}/remote.php/dav/versions/${encodeURIComponent(OWNER.userId)}/versions/${fileId}` - return listDavVersions(OWNER, ownerCollectionUrl).then(({ body }) => { - const ownerEntries = versionEntries(body) - expect(ownerEntries.length, 'owner-visible version snapshots').to.be.greaterThan(1) - const sourceUrl = new URL(ownerEntries[0].href, ownerCollectionUrl).href - return authenticatedDavRequest(OWNER, { url: sourceUrl }).then(({ body: historicalBytes }) => { - return authenticatedDavRequest(OWNER, { - method: 'MOVE', - url: sourceUrl, - headers: { Destination: `${Cypress.expose('baseUrl')}/remote.php/dav/versions/${encodeURIComponent(OWNER.userId)}/restore/target` }, - }).then(({ status }) => { - expect(status).to.be.oneOf([201, 204]) - return authenticatedDavRequest(OWNER, { url: pageUrl }) - }).its('body').should('equal', historicalBytes) - }) - }) - }) - }) - - it('C07 saves no-wait editor bytes before fetching a current comparison', function() { - cy.login(OWNER) - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${RAPID_COMPARE_PAGE_NAME}`) - cy.switchToEditMode() - cy.intercept('POST', '**/apps/text/session/*/save').as('comparisonPreparationSave') - cy.intercept('GET', CURRENT_SNAPSHOT_URL).as('preparedCurrentSnapshot') - const typedBytes = 'No-wait current bytes 7f56c599' - insertEditorContent(typedBytes, true) - - openVersionsSidebar() - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@comparisonPreparationSave') - cy.wait('@preparedCurrentSnapshot') - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents').click() - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', typedBytes) - cy.get('[data-cy-collectives="editor"] .ProseMirror').should('exist') - cy.location('search').should('match', /compareTo=current(?::|%3A)/) - cy.reload() - waitForSemanticComparison() - cy.contains('.version-comparison-dialog [role="tab"]', 'Full documents').click() - cy.get('.version-comparison-dialog .text-comparison__document--after') - .should('contain', typedBytes) - closeSemanticComparison() - }) - - it('C08 prevents snapshot reads and reports an actionable preparation failure', function() { - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${RAPID_COMPARE_PAGE_NAME}`) - cy.switchToEditMode() - cy.intercept('POST', '**/apps/text/session/*/save', { statusCode: 500 }).as('failedPreparationSave') - cy.intercept('GET', HISTORICAL_SNAPSHOT_URL).as('historicalSnapshotRequest') - cy.intercept('GET', CURRENT_SNAPSHOT_URL).as('currentSnapshotRequest') - insertEditorContent('Preparation must fail before snapshot reads', true) - openVersionsSidebar() - cy.contains('button', 'Compare versions…').click() - getVersionComparisonModal().find('button[type="submit"]').click() - cy.wait('@failedPreparationSave') - cy.get('.version-comparison-dialog [role="alert"]') - .should('contain', 'Could not save current changes before comparison. Please try again.') - cy.get('@historicalSnapshotRequest.all').should('have.length', 0) - cy.get('@currentSnapshotRequest.all').should('have.length', 0) - cy.get('.version-comparison-dialog .text-comparison').should('not.exist') - }) - - it('restores the initial version through DAV MOVE', function() { + it('Restore initial version', function() { cy.get('.app-sidebar-tabs__content .version-list .list-item') .eq(3) .find('.list-item-content__actions') @@ -1559,82 +107,30 @@ describeSemantic('Page versions semantic comparison', function() { cy.intercept('MOVE', '**/dav/versions/**').as('moveVersion') cy.clickMenuButton('Restore version') - cy.wait('@moveVersion').its('response.statusCode').should('be.oneOf', [201, 204]) + cy.wait('@moveVersion') cy.contains('[role="status"]', 'Restored').should('be.visible') + // Check the restored file content via WebDAV instead of reloading: + // reloading makes the text client replay its cached (pre-restore) + // document state into the open session, overwriting the restore. cy.request('/csrftoken').then(({ body }) => { cy.request({ - url: `${Cypress.expose('baseUrl')}/remote.php/webdav/.Collectives/${encodeURIComponent(COLLECTIVE_NAME)}/${encodeURIComponent(PAGE_NAME)}.md`, + url: `${Cypress.expose('baseUrl')}/remote.php/webdav/.Collectives/${encodeURIComponent('Versions Collective')}/Page.md`, headers: { requesttoken: body.token }, - }).its('body') - .should('contain', INITIAL_PHRASE) - .and('not.contain', CURRENT_PHRASE) + }).its('body').should('contain', 'v1') }) }) it('Delete version', function() { cy.get('.app-sidebar-tabs__content .version-list .list-item') - .then(($versions) => { - cy.wrap($versions) - .filter(':not(:first)') - .first() - .find('.list-item-content__actions') - .click() + .eq(2) + .find('.list-item-content__actions') + .click() - cy.intercept('DELETE', '**/dav/versions/**').as('deleteVersion') - cy.clickMenuButton('Delete version') - cy.wait('@deleteVersion') + cy.intercept('MOVE', '**/dav/versions/**').as('moveVersion') + cy.clickMenuButton('Delete version') - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('have.length', $versions.length - 1) - }) + cy.get('.app-sidebar-tabs__content .version-list .list-item') + .should('have.length', 3) }) }) - -if (!SEMANTIC_E2E) { - describe('Page versions Viewer fallback', function() { - before(function() { - provisionTestUser(OWNER) - cy.login(OWNER) - cy.deleteAndSeedCollective(COLLECTIVE_NAME) - .seedPage(PAGE_NAME, '', 'Readme.md') - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent(`${COLLECTIVE_NAME}/${PAGE_NAME}.md`, INITIAL_CONTENT) - .wait(1100) - cy.seedPageContent(`${COLLECTIVE_NAME}/${PAGE_NAME}.md`, CURRENT_CONTENT) - }) - - after(function() { - cy.login(OWNER) - cy.deleteCollective(COLLECTIVE_NAME) - deleteTestUser(OWNER) - }) - - it('AUD-06 stable branches compare through Viewer without the semantic Text factory', function() { - cy.login(OWNER) - cy.visit(`/apps/collectives/${COLLECTIVE_NAME}/${PAGE_NAME}`) - cy.window().then((window) => { - if (typeof window.OCA?.Text?.createMarkdownContentComparison === 'function') { - cy.stub(window.OCA.Text, 'createMarkdownContentComparison').value(undefined) - } - expect(window.OCA?.Text?.createMarkdownContentComparison).not.to.be.a('function') - }) - openVersionsSidebar() - cy.contains('.app-sidebar-tabs__content .version-list .list-item', 'Initial version') - .find('.list-item-content__actions') - .click() - cy.clickMenuButton('Compare with current version') - - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal .viewer__comparison > *:visible, #viewer .viewer--split > .viewer__file-wrapper:visible') - .should('have.length', 2) - .first() - .should('contain', INITIAL_PHRASE) - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal .viewer__comparison > *:visible, #viewer .viewer--split > .viewer__file-wrapper:visible') - .eq(1) - .should('contain', CURRENT_PHRASE) - closeViewerComparison() - }) - }) -} diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 5017220157..68c322d968 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -389,7 +389,7 @@ Cypress.Commands.add('stubClipboardAndVisit', (url) => { }) Cypress.Commands.add('getClipboardText', () => { - return cy.get('@clipboardWriteText') + cy.get('@clipboardWriteText') .should('have.been.called') - .then((writeText) => writeText.lastCall.args[0]) + .its('lastCall.args[0]') }) diff --git a/playwright.config.ts b/playwright.config.ts index 3786cea693..d5208193f8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,12 +23,7 @@ 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 @@ -37,21 +32,6 @@ const CI_CONFIG = { const baseURL = process.env.baseURL || 'http://localhost:8089/index.php/' const externalServer = Boolean(process.env.baseURL) -const SEMANTIC_E2E = process.env.COLLECTIVES_SEMANTIC_E2E === '1' -const VERSION_COMPARISON_TESTS = '**/version-comparison.spec.ts' -const VIEWER_FALLBACK_TAG = /@viewer-fallback/ - -const semanticComparisonProjects = SEMANTIC_E2E - ? [{ - name: 'comparison-chromium', - testMatch: VERSION_COMPARISON_TESTS, - grepInvert: VIEWER_FALLBACK_TAG, - use: { - ...devices['Desktop Chrome'], - ignoreHTTPSErrors: true, - }, - }] - : [] const webServer = externalServer ? undefined @@ -98,21 +78,10 @@ export default defineConfig({ projects: [ { name: 'chromium', - testIgnore: VERSION_COMPARISON_TESTS, - use: { - ...devices['Desktop Chrome'], - }, - }, - { - name: 'comparison-viewer-chromium', - testMatch: VERSION_COMPARISON_TESTS, - grep: VIEWER_FALLBACK_TAG, use: { ...devices['Desktop Chrome'], - ignoreHTTPSErrors: true, }, }, - ...semanticComparisonProjects, ], webServer, diff --git a/playwright/e2e/version-comparison.spec.ts b/playwright/e2e/version-comparison.spec.ts deleted file mode 100644 index 2e51b68eda..0000000000 --- a/playwright/e2e/version-comparison.spec.ts +++ /dev/null @@ -1,739 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User as Account } from '@nextcloud/e2e-test-server' -import type { Page } from '@playwright/test' -import type { Collective } from '../support/fixtures/Collective.ts' -import type { CollectivePage } from '../support/fixtures/CollectivePage.ts' - -import { docker, getContainer, runOcc } from '@nextcloud/e2e-test-server/docker' -import { test as base, expect, mergeTests } from '@playwright/test' -import { randomUUID } from 'node:crypto' -import { test as editorTest } from '../support/fixtures/editor.ts' -import { loginAsUser } from '../support/fixtures/random-user.ts' -import { User } from '../support/fixtures/User.ts' -import { apiUrl, circlesApiUrl, ocsHeaders } from '../support/helpers/urls.ts' -import { - createVersionComparisonAccount, - deleteVersionComparisonUser, - provisionVersionComparisonUser, -} from '../support/helpers/versionComparisonFixtures.ts' - -const SNAPSHOT_URL = /\/remote\.php\/dav\/(?:versions|files)\// -const container = process.env.PLAYWRIGHT_NC_CONTAINER - ? docker.getContainer(process.env.PLAYWRIGHT_NC_CONTAINER) - : getContainer() -const runPersistentStackOcc = (command: string[], options = {}) => runOcc(command, { container, ...options }) -const runNamespace = randomUUID().slice(0, 8) - -interface ProvisionedFixtures { - collective: Collective -} - -interface ProvisionedWorkerFixtures { - account: Account - readerAccount: Account - user: User -} - -function namespacedAccount(prefix: string, workerIndex: number, projectName: string): Account { - return createVersionComparisonAccount(`${prefix}-${runNamespace}-${projectName.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`, workerIndex, randomUUID) as Account -} - -async function provisionAccount(account: Account) { - await provisionVersionComparisonUser(account, runPersistentStackOcc) -} - -async function deleteAccount(account: Account) { - await deleteVersionComparisonUser(account.userId, runPersistentStackOcc) -} - -const provisionedTest = base.extend({ - // eslint-disable-next-line no-empty-pattern - account: [async ({}, use, workerInfo) => { - const account = namespacedAccount('pw-owner', workerInfo.workerIndex, workerInfo.project.name) - await provisionAccount(account) - await use(account) - await deleteAccount(account) - }, { scope: 'worker' }], - // eslint-disable-next-line no-empty-pattern - readerAccount: [async ({}, use, workerInfo) => { - const account = namespacedAccount('pw-reader', workerInfo.workerIndex, workerInfo.project.name) - await provisionAccount(account) - await use(account) - await deleteAccount(account) - }, { scope: 'worker' }], - page: async ({ account, baseURL, browser }, use) => { - const page = await loginAsUser(browser, baseURL, account) - await use(page) - await page.close() - }, - user: [async ({ account }, use) => { - await use(new User(account)) - }, { scope: 'worker' }], - collective: async ({ page, user }, use) => { - const collective = await user.createCollective({ - name: `c599-e2e-pw-${randomUUID()}`, - }, page) - await use(collective) - await user.deleteCollective({ id: collective.data.id }, page) - }, -}) - -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() - } - await tab.click() -} - -async function seedVersionPair(collectivePage: CollectivePage, user: User, page: Page) { - await collectivePage.setContent({ content: 'Historical comparison bytes', user, page }) - await page.waitForTimeout(1100) - await collectivePage.setContent({ content: 'Current comparison bytes', user, page }) -} - -async function seedTwoHistoricalVersions(collectivePage: CollectivePage, user: User, page: Page) { - await collectivePage.setContent({ content: 'First historical comparison bytes', user, page }) - await page.waitForTimeout(1100) - await collectivePage.setContent({ content: 'Second historical comparison bytes', user, page }) - await page.waitForTimeout(1100) - await collectivePage.setContent({ content: 'Current comparison bytes', user, page }) -} - -async function openSeededVersionSelector(collective: Collective, user: User, page: Page, title: string) { - const collectivePage = await collective.createPage({ title, user, page }) - await seedVersionPair(collectivePage, user, page) - await collectivePage.open() - await openVersions(page) - const opener = page.getByRole('button', { name: 'Compare versions…' }) - await opener.click() - const dialog = page.getByRole('dialog', { name: 'Compare versions' }) - await expect(dialog).toBeVisible() - await expect.poll(() => dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true) - return { dialog, opener } -} - -async function openSeededComparison(collective: Collective, user: User, page: Page, title: string) { - const { dialog, opener } = await openSeededVersionSelector(collective, user, page, title) - await dialog.getByRole('button', { name: 'Compare', exact: true }).click() - await expect(page.locator('.text-comparison')).toBeVisible() - return { dialog, opener } -} - -function auditComparisonFailures(page: Page) { - const failures: string[] = [] - let sawEmptyUserStatus = false - page.on('console', (message) => { - const text = message.text() - const isExpectedEmptyUserStatus = text.includes('core: Failed to load user status') - // The test server's files_pdfviewer serves HTML instead of its stylesheet - const isMissingPdfViewerStyle = text.includes('/files_pdfviewer/css/') - if (message.type() === 'error' && !isExpectedEmptyUserStatus && !isMissingPdfViewerStyle) { - failures.push(`console: ${text}`) - } - }) - page.on('pageerror', (error) => { - if (error.message !== 'ResizeObserver loop completed with undelivered notifications.') { - failures.push(`page: ${error.message}`) - } - }) - page.on('requestfailed', (request) => { - if (request.url().includes('/files_pdfviewer/css/')) { - return - } - failures.push(`request: ${request.url()} ${request.failure()?.errorText ?? 'failed'}`) - }) - page.on('response', (response) => { - if (response.status() === 404 && new URL(response.url()).pathname.endsWith('/apps/user_status/api/v1/user_status')) { - sawEmptyUserStatus = true - return - } - if (response.status() >= 400) { - failures.push(`response: ${response.status()} ${response.url()}`) - } - }) - return () => expect(failures.filter((failure) => !( - sawEmptyUserStatus - && /^console: Failed to load resource: the server responded with a status of 404(?: \(Not Found\)| \(\))?$/.test(failure) - ))).toEqual([]) -} - -async function installClipboardCapture(page: Page) { - await page.evaluate(() => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { - writeText: async (value: string) => sessionStorage.setItem('c599-copied-link', value), - }, - }) - }) -} - -async function freshAuthenticatedContext(page: Page, serviceWorkers: 'allow' | 'block' = 'allow') { - return await page.context().browser()!.newContext({ - baseURL: process.env.baseURL || 'http://localhost:8089/index.php/', - ignoreHTTPSErrors: true, - serviceWorkers, - storageState: await page.context().storageState(), - }) -} - -test.describe('Version comparison route and current-byte contract', () => { - test('C01 displays a historical snapshot before the current snapshot', async ({ user, page, collective }) => { - await openSeededComparison(collective, user, page, 'c599-e2e-current-historical-page') - await page.getByRole('tab', { name: 'Full documents' }).click() - await expect(page.locator('.text-comparison__document--before')).toContainText('Historical comparison bytes') - await expect(page.locator('.text-comparison__document--after')).toContainText('Current comparison bytes') - }) - - test('C07 sends unsaved editor bytes to the current comparison', async ({ user, page, collective, editor }) => { - const collectivePage = await collective.createPage({ title: 'c599-e2e-comparison-page', user, page }) - await seedVersionPair(collectivePage, user, page) - await collectivePage.open() - const sessionCreated = page.waitForResponse((response) => response.request().method() === 'PUT' - && /\/apps\/text\/session\/.*\/create/.test(response.url())) - await collectivePage.switchMode(true) - await sessionCreated - await expect(page.locator('.text-menubar--ready')).toBeVisible() - editor.setMode(true) - const typedBytes = 'No-wait Playwright bytes 7f56c599' - await editor.getContent().fill(typedBytes) - - await openVersions(page) - await page.getByRole('button', { name: 'Compare versions…' }).click() - await page.getByRole('dialog').getByRole('button', { name: 'Compare', exact: true }).click() - await page.getByRole('tab', { name: 'Full documents' }).click() - await expect(page.locator('.text-comparison__document--after')).toContainText(typedBytes) - await expect(collectivePage.getContent(true)).toBeAttached() - }) - - test('R01 encodes the exact ordered snapshot pair in the canonical route', async ({ user, page, collective }) => { - await openSeededComparison(collective, user, page, 'c599-e2e-canonical-route-page') - const comparison = new URL(page.url()) - expect(comparison.searchParams.get('compareFrom')).toMatch(/^version:[^/\\]+$/) - expect(comparison.searchParams.get('compareTo')).toMatch(/^current:[^/\\]+$/) - const selectors = page.locator('.version-comparison-dialog select') - await expect(selectors.nth(0)).toHaveValue(comparison.searchParams.get('compareFrom')!) - await expect(selectors.nth(1)).toHaveValue('current') - }) - - test('R02 reload restores the exact comparison pair and view', async ({ user, page, collective }) => { - await openSeededComparison(collective, user, page, 'c599-e2e-reload-route-page') - await page.getByRole('tab', { name: 'Full documents' }).click() - const comparisonUrl = page.url() - const comparison = new URL(comparisonUrl) - - await page.reload() - - await expect(page.locator('.text-comparison')).toBeVisible() - await page.getByRole('tab', { name: 'Full documents' }).click() - await expect(page.locator('.text-comparison__document--before')).toContainText('Historical comparison bytes') - await expect(page.locator('.text-comparison__document--after')).toContainText('Current comparison bytes') - const restored = new URL(page.url()) - expect(restored.searchParams.get('compareFrom')).toBe(comparison.searchParams.get('compareFrom')) - expect(restored.searchParams.get('compareTo')).toBe(comparison.searchParams.get('compareTo')) - }) - - test('R03 Back closes the managed comparison and restores the prior route', async ({ user, page, collective }) => { - await openSeededComparison(collective, user, page, 'c599-e2e-back-route-page') - - await page.goBack() - - await expect(page.locator('.version-comparison-dialog')).toHaveCount(0) - const restored = new URL(page.url()) - expect(restored.searchParams.has('compareFrom')).toBe(false) - expect(restored.searchParams.has('compareTo')).toBe(false) - }) - - test('R04 Forward reopens the exact managed comparison state', async ({ user, page, collective }) => { - await openSeededComparison(collective, user, page, 'c599-e2e-forward-route-page') - const comparisonUrl = page.url() - - await page.goBack() - await expect(page.locator('.version-comparison-dialog')).toHaveCount(0) - await page.goForward() - - await expect(page).toHaveURL(comparisonUrl) - await expect(page.locator('.text-comparison')).toBeVisible() - }) - - test('R05 copied link opens the exact pair in a fresh authenticated context', async ({ user, page, collective }) => { - await openSeededComparison(collective, user, page, 'c599-e2e-copied-route-page') - const comparisonUrl = page.url() - - await installClipboardCapture(page) - await page.getByRole('button', { name: 'Copy comparison link' }).click() - const copiedUrl = await page.evaluate(() => sessionStorage.getItem('c599-copied-link')) - expect(copiedUrl).toBe(comparisonUrl) - - const copiedContext = await freshAuthenticatedContext(page) - const copiedPage = await copiedContext.newPage() - await copiedPage.goto(copiedUrl!) - await expect(copiedPage.locator('.text-comparison')).toBeVisible() - await copiedPage.getByRole('tab', { name: 'Full documents' }).click() - await expect(copiedPage.locator('.text-comparison__document--before')).toContainText('Historical comparison bytes') - await expect(copiedPage.locator('.text-comparison__document--after')).toContainText('Current comparison bytes') - const copiedPair = new URL(copiedPage.url()) - expect(copiedPair.searchParams.get('compareFrom')).toBe(new URL(comparisonUrl).searchParams.get('compareFrom')) - expect(copiedPair.searchParams.get('compareTo')).toBe(new URL(comparisonUrl).searchParams.get('compareTo')) - await copiedContext.close() - }) - - test('F11 completes comparison with no unexplained browser or network failures', async ({ user, page, collective }) => { - const assertNoFailures = auditComparisonFailures(page) - await openSeededComparison(collective, user, page, 'c599-e2e-clean-failures-page') - await page.waitForLoadState('networkidle') - - assertNoFailures() - }) - - test('C02 loads two immutable historical snapshots in chronological panes', async ({ user, page, collective }) => { - const collectivePage = await collective.createPage({ title: 'c599-e2e-historical-pair-page', user, page }) - await seedTwoHistoricalVersions(collectivePage, user, page) - await collectivePage.open() - await openVersions(page) - await page.getByRole('button', { name: 'Compare versions…' }).click() - const selectors = page.locator('.version-comparison-dialog select') - await selectors.nth(0).selectOption({ index: 2 }) - await selectors.nth(1).selectOption({ index: 1 }) - await page.getByRole('dialog').getByRole('button', { name: 'Compare', exact: true }).click() - await page.getByRole('tab', { name: 'Full documents' }).click() - await expect(page.locator('.text-comparison__document--before')).toContainText('First historical comparison bytes') - await expect(page.locator('.text-comparison__document--after')).toContainText('Second historical comparison bytes') - }) - - test('C15 read-only member compares only permitted snapshots', async ({ readerAccount, user, page, baseURL, browser, collective }) => { - const collectivePage = await collective.createPage({ title: 'c599-e2e-read-only-comparison-page', user, page }) - await seedVersionPair(collectivePage, user, page) - const memberResponse = await page.request.post(circlesApiUrl(collective.data.circleId, 'members'), { - headers: ocsHeaders, - data: { userId: readerAccount.userId, type: 1 }, - failOnStatusCode: true, - }) - const memberBody = await memberResponse.json() - expect(memberBody.ocs.meta.statuscode).toBe(200) - expect(memberBody.ocs.data).toMatchObject({ userId: readerAccount.userId }) - await page.request.put(circlesApiUrl(collective.data.circleId, 'members', memberBody.ocs.data.id, 'level'), { - headers: ocsHeaders, - data: { level: 4 }, - failOnStatusCode: true, - }) - await page.request.put(apiUrl('v1.0', 'collectives', collective.data.id, 'editLevel'), { - headers: ocsHeaders, - data: { level: 8 }, - failOnStatusCode: true, - }) - - const readerPage = await loginAsUser(browser, baseURL, readerAccount) - const snapshotStatuses: number[] = [] - readerPage.on('response', (response) => { - if (SNAPSHOT_URL.test(response.url())) { - snapshotStatuses.push(response.status()) - } - }) - await readerPage.goto(collectivePage.getPageUrl()) - await expect(readerPage.locator('button.titleform-button')).toHaveCount(0) - await openVersions(readerPage) - await readerPage.getByRole('button', { name: 'Compare versions…' }).click() - await readerPage.getByRole('dialog').getByRole('button', { name: 'Compare', exact: true }).click() - await expect(readerPage.locator('.text-comparison')).toBeVisible() - expect(snapshotStatuses.length).toBeGreaterThan(0) - expect(snapshotStatuses.every((status) => status >= 200 && status < 300)).toBe(true) - await readerPage.close() - }) - - test('C16 rejects public comparison parameters without snapshot requests', async ({ user, page, collective }) => { - const collectivePage = await collective.createPage({ title: 'c599-e2e-public-comparison-page', user, page }) - const share = await collective.createShare({ page }) - try { - const snapshotRequests: string[] = [] - page.on('request', (request) => { - if (SNAPSHOT_URL.test(request.url())) { - snapshotRequests.push(request.url()) - } - }) - await page.goto(`${collectivePage.getPageUrl(share.data.token)}?compareFrom=version:1&compareTo=current:2&view=grid#kept`) - await expect(page.locator('#tab-button-versions')).toHaveCount(0) - await expect(page.locator('.version-comparison-dialog')).toHaveCount(0) - await expect(page).toHaveURL(/\?view=grid#kept$/) - expect(snapshotRequests).toEqual([]) - } finally { - await share.delete() - } - }) - - test('R06 preserves the exact pair while canonicalizing a renamed page path', async ({ user, page, collective }) => { - const collectivePage = await collective.createPage({ title: 'c599-e2e-route-rename-page', user, page }) - await seedVersionPair(collectivePage, user, page) - await collectivePage.open() - await openVersions(page) - await page.getByRole('button', { name: 'Compare versions…' }).click() - await page.getByRole('dialog').getByRole('button', { name: 'Compare', exact: true }).click() - await expect(page.locator('.text-comparison')).toBeVisible() - const beforeRename = new URL(page.url()) - const pair = [beforeRename.searchParams.get('compareFrom'), beforeRename.searchParams.get('compareTo')] - - await page.request.put(apiUrl('v1.0', 'collectives', collective.data.id, 'pages', collectivePage.data.id), { - headers: ocsHeaders, - data: { title: 'c599-e2e-renamed-comparison-page' }, - failOnStatusCode: true, - }) - await page.reload() - await expect(page).toHaveURL(/c599-e2e-renamed-comparison-page/) - const afterRename = new URL(page.url()) - expect([afterRename.searchParams.get('compareFrom'), afterRename.searchParams.get('compareTo')]).toEqual(pair) - await expect(page.locator('.text-comparison')).toBeVisible() - }) - - test('AUD-03 binds displayed current bytes and route identity to the committed snapshot', async ({ user, page, collective, editor }) => { - const collectivePage = await collective.createPage({ title: 'c599-e2e-committed-generation-page', user, page }) - await seedVersionPair(collectivePage, user, page) - await collectivePage.open() - const sessionCreated = page.waitForResponse((response) => response.request().method() === 'PUT' - && /\/apps\/text\/session\/.*\/create/.test(response.url())) - await collectivePage.switchMode(true) - await sessionCreated - await expect(page.locator('.text-menubar--ready')).toBeVisible() - editor.setMode(true) - const typedBytes = 'Committed generation bytes 7f56c599' - await editor.getContent().fill(typedBytes) - const currentSnapshotGets: string[] = [] - page.on('request', (request) => { - if (request.method() === 'GET' - && /\/remote\.php\/dav\/versions\//.test(request.url()) - && new URL(request.url()).searchParams.has('timestamp')) { - currentSnapshotGets.push(request.url()) - } - }) - - await openVersions(page) - await page.getByRole('button', { name: 'Compare versions…' }).click() - await page.getByRole('dialog').getByRole('button', { name: 'Compare', exact: true }).click() - await page.getByRole('tab', { name: 'Full documents' }).click() - - await expect(page.locator('.text-comparison__document--after')).toContainText(typedBytes) - await expect.poll(() => currentSnapshotGets.length).toBeGreaterThan(0) - const currentSnapshotUrl = new URL(currentSnapshotGets[currentSnapshotGets.length - 1]) - const pathParts = currentSnapshotUrl.pathname.split('/') - const requestedIdentity = decodeURIComponent(pathParts[pathParts.length - 1]) - expect(new URL(page.url()).searchParams.get('compareTo')).toBe(`current:${requestedIdentity}`) - }) - - test('AUD-08 bounds streamed snapshot bodies and does not cache oversized failures', async ({ user, page, collective }) => { - const oversizedBody = 'x'.repeat(2_000_001) - let completeHistoricalGets = 0 - const collectivePage = await collective.createPage({ title: 'c599-e2e-stream-limit-page', user, page }) - await seedVersionPair(collectivePage, user, page) - const context = await freshAuthenticatedContext(page, 'block') - const boundedPage = await context.newPage() - await boundedPage.route(/\/remote\.php\/dav\/versions\//, async (route) => { - const request = route.request() - const url = new URL(request.url()) - if (request.method() !== 'GET' || url.searchParams.has('timestamp')) { - await route.continue() - return - } - if (request.headers().range) { - await route.fulfill({ - body: 'Bounded historical preview', - headers: { 'Content-Range': 'bytes 0-25/2000001' }, - status: 206, - }) - return - } - completeHistoricalGets += 1 - await route.fulfill({ body: oversizedBody, status: 200 }) - }) - - try { - await boundedPage.goto(collectivePage.getPageUrl()) - await boundedPage.locator('[data-cy-collectives="reader"] .ProseMirror').waitFor({ state: 'visible' }) - await openVersions(boundedPage) - await boundedPage.getByRole('button', { name: 'Compare versions…' }).click() - const dialog = boundedPage.getByRole('dialog', { name: 'Compare versions' }) - await expect(dialog).toBeVisible() - await dialog.getByRole('button', { name: 'Compare', exact: true }).click() - - await expect(dialog.getByRole('alert')).toContainText('too large for complete comparison') - await expect(dialog.locator('.version-comparison-dialog__preview')).toContainText('Bounded historical preview') - await expect(dialog.locator('.text-comparison')).toHaveCount(0) - expect(completeHistoricalGets).toBe(1) - - await dialog.getByRole('button', { name: 'Retry' }).click() - await expect.poll(() => completeHistoricalGets).toBe(2) - await expect(dialog.getByRole('alert')).toContainText('too large for complete comparison') - await expect(dialog.locator('.text-comparison')).toHaveCount(0) - } finally { - await context.close() - } - }) - - test('AUD-15 clears the historical snapshot cache when the dialog closes', async ({ user, page, collective }) => { - const historicalSnapshotGets: string[] = [] - page.on('request', (request) => { - const url = new URL(request.url()) - if (request.method() === 'GET' - && /\/remote\.php\/dav\/versions\//.test(url.pathname) - && !url.searchParams.has('timestamp')) { - historicalSnapshotGets.push(request.url()) - } - }) - const { dialog } = await openSeededComparison(collective, user, page, 'c599-e2e-cache-lifecycle-page') - await expect.poll(() => historicalSnapshotGets.length).toBe(1) - - await dialog.focus() - await page.keyboard.press('Escape') - await expect(page.locator('.version-comparison-dialog')).toHaveCount(0) - await page.getByRole('button', { name: 'Compare versions…' }).click() - await page.getByRole('dialog').getByRole('button', { name: 'Compare', exact: true }).click() - await expect(page.locator('.text-comparison')).toBeVisible() - - await expect.poll(() => historicalSnapshotGets.length).toBe(2) - }) - - test('AUD-20 rejects current aliases that resolve to the same snapshot before body fetch', async ({ user, page, collective }) => { - await openSeededComparison(collective, user, page, 'c599-e2e-route-self-pair-page') - const currentRouteId = new URL(page.url()).searchParams.get('compareTo') - expect(currentRouteId).toMatch(/^current:[^/\\]+$/) - await page.getByRole('dialog', { name: 'Compare versions' }).getByRole('button', { name: 'Close' }).click() - await expect(page.locator('.version-comparison-dialog')).toHaveCount(0) - - const snapshotGets: string[] = [] - page.on('request', (request) => { - if (request.method() === 'GET' && /\/remote\.php\/dav\/versions\//.test(request.url())) { - snapshotGets.push(request.url()) - } - }) - const aliasUrl = new URL(page.url()) - aliasUrl.searchParams.set('compareFrom', currentRouteId!) - aliasUrl.searchParams.set('compareTo', 'current') - await page.goto(aliasUrl.toString()) - - const dialog = page.getByRole('dialog', { name: 'Compare versions' }) - await expect(dialog.getByRole('status')).toContainText('Select two different versions.') - await expect(dialog.getByRole('alert')).toHaveCount(0) - await expect(dialog.getByRole('button', { name: 'Retry' })).toHaveCount(0) - expect(snapshotGets).toEqual([]) - }) - - test('AUD-09 settled forward and backward Tab remain inside the comparison dialog', async ({ user, page, collective }) => { - const { dialog } = await openSeededVersionSelector(collective, user, page, 'c599-e2e-focus-containment-page') - const focusableSelector = 'a[href], button:not([disabled]), select:not([disabled]), textarea:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' - await expect.poll(async () => dialog.evaluate((element, selector) => ( - Array.from(element.querySelectorAll(selector)) - .filter((candidate) => candidate.getClientRects().length > 0) - .length - ), focusableSelector)).toBeGreaterThan(1) - - await dialog.evaluate((element, selector) => { - const focusable = Array.from(element.querySelectorAll(selector)) - .filter((candidate) => candidate.getClientRects().length > 0) - focusable[focusable.length - 1]?.focus() - }, focusableSelector) - await page.keyboard.press('Tab') - expect(await dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true) - - await dialog.evaluate((element, selector) => { - const focusable = Array.from(element.querySelectorAll(selector)) - .filter((candidate) => candidate.getClientRects().length > 0) - focusable[0]?.focus() - }, focusableSelector) - await page.keyboard.press('Shift+Tab') - expect(await dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true) - }) - - test('AUD-09 Escape restores focus to the exact comparison opener', async ({ user, page, collective }) => { - const { dialog, opener } = await openSeededVersionSelector(collective, user, page, 'c599-e2e-focus-restore-page') - - await dialog.focus() - await page.keyboard.press('Escape') - - await expect(dialog).toHaveCount(0) - await expect(opener).toBeFocused() - }) - - test('AUD-21 throwing destroy still clears dialog route cache and restores focus', async ({ user, page, collective }) => { - const { dialog, opener } = await openSeededVersionSelector(collective, user, page, 'c599-e2e-throwing-destroy-page') - const historicalSnapshotGets: string[] = [] - page.on('request', (request) => { - if (request.method() === 'GET' && /\/remote\.php\/dav\/versions\//.test(request.url())) { - historicalSnapshotGets.push(request.url()) - } - }) - await page.evaluate(() => { - const auditWindow = window as typeof window & { __c599DestroyCalls?: number } - auditWindow.__c599DestroyCalls = 0 - Object.defineProperty(window.OCA.Text, 'createMarkdownContentComparison', { - configurable: true, - value: async ({ el }: { el: HTMLElement }) => { - const marker = document.createElement('div') - marker.className = 'c599-throwing-destroy-comparison' - marker.textContent = 'Comparison ready' - el.append(marker) - return { - destroy() { - auditWindow.__c599DestroyCalls! += 1 - throw new Error('AUD-21 destroy failure') - }, - } - }, - }) - }) - - await dialog.getByRole('button', { name: 'Compare', exact: true }).click() - await expect(dialog.locator('.c599-throwing-destroy-comparison')).toBeVisible() - await expect.poll(() => new URL(page.url()).searchParams.has('compareFrom')).toBe(true) - expect(historicalSnapshotGets.length).toBeGreaterThan(0) - const firstFetchCount = historicalSnapshotGets.length - - await dialog.focus() - await page.keyboard.press('Escape') - - await expect(dialog).toHaveCount(0) - await expect.poll(() => { - const url = new URL(page.url()) - return [url.searchParams.get('compareFrom'), url.searchParams.get('compareTo')] - }).toEqual([null, null]) - await expect(opener).toBeFocused() - await expect.poll(() => page.evaluate(() => ( - window as typeof window & { __c599DestroyCalls?: number } - ).__c599DestroyCalls)).toBe(1) - - await opener.click() - await dialog.getByRole('button', { name: 'Compare', exact: true }).click() - await expect(dialog.locator('.c599-throwing-destroy-comparison')).toBeVisible() - await expect.poll(() => historicalSnapshotGets.length).toBeGreaterThan(firstFetchCount) - }) - - test('AUD-21 malformed semantic factory fails closed without publishing content or route', async ({ user, page, collective }) => { - const { dialog } = await openSeededVersionSelector(collective, user, page, 'c599-e2e-malformed-factory-page') - await page.evaluate(() => { - Object.defineProperty(window.OCA.Text, 'createMarkdownContentComparison', { - configurable: true, - value: async ({ el }: { el: HTMLElement }) => { - const marker = document.createElement('div') - marker.className = 'c599-malformed-factory-content' - el.append(marker) - return {} - }, - }) - }) - - await dialog.getByRole('button', { name: 'Compare', exact: true }).click() - - await expect(dialog.getByRole('alert')).toContainText('Could not initialize version comparison.') - await expect(dialog.locator('.c599-malformed-factory-content')).toHaveCount(0) - await expect(dialog.locator('.version-comparison-dialog__comparison')).toBeEmpty() - const url = new URL(page.url()) - expect([url.searchParams.get('compareFrom'), url.searchParams.get('compareTo')]).toEqual([null, null]) - }) - - test('AUD-21 delayed stale factory instance is destroyed exactly once', async ({ user, page, collective }) => { - const { dialog, opener } = await openSeededVersionSelector(collective, user, page, 'c599-e2e-delayed-stale-factory-page') - await page.evaluate(() => { - interface DelayedFactoryState { - destroyCalls: number - pending: boolean - release?: () => void - } - const auditWindow = window as typeof window & { __c599DelayedFactory?: DelayedFactoryState } - const state: DelayedFactoryState = { destroyCalls: 0, pending: false } - auditWindow.__c599DelayedFactory = state - Object.defineProperty(window.OCA.Text, 'createMarkdownContentComparison', { - configurable: true, - value: async ({ el }: { el: HTMLElement }) => { - const marker = document.createElement('div') - marker.className = 'c599-delayed-factory-content' - el.append(marker) - state.pending = true - await new Promise((resolve) => { - state.release = resolve - }) - return { - destroy() { - state.destroyCalls += 1 - }, - } - }, - }) - }) - - await dialog.getByRole('button', { name: 'Compare', exact: true }).click() - await expect.poll(() => page.evaluate(() => ( - window as typeof window & { __c599DelayedFactory?: { pending: boolean } } - ).__c599DelayedFactory?.pending)).toBe(true) - - await dialog.focus() - await page.keyboard.press('Escape') - await expect(dialog).toHaveCount(0) - await expect(opener).toBeFocused() - await page.evaluate(() => ( - window as typeof window & { __c599DelayedFactory?: { release?: () => void } } - ).__c599DelayedFactory?.release?.()) - await expect.poll(() => page.evaluate(() => ( - window as typeof window & { __c599DelayedFactory?: { destroyCalls: number } } - ).__c599DelayedFactory?.destroyCalls)).toBe(1) - await page.waitForTimeout(100) - expect(await page.evaluate(() => ( - window as typeof window & { __c599DelayedFactory?: { destroyCalls: number } } - ).__c599DelayedFactory?.destroyCalls)).toBe(1) - await expect(page.locator('.c599-delayed-factory-content')).toHaveCount(0) - }) - - test('X03 uses existing Viewer.compare when the Text semantic factory is absent', { tag: '@viewer-fallback' }, async ({ user, page, collective }) => { - const assertNoFailures = auditComparisonFailures(page) - const collectivePage = await collective.createPage({ title: 'c599-e2e-viewer-fallback-page', user, page }) - await seedVersionPair(collectivePage, user, page) - await collectivePage.open() - await openVersions(page) - await page.evaluate(() => { - Object.defineProperty(window.OCA.Text, 'createMarkdownContentComparison', { - configurable: true, - value: async ({ el }: { el: HTMLElement }) => { - const marker = document.createElement('div') - marker.className = 'viewer-fallback-route-source' - el.append(marker) - return { destroy: () => marker.remove() } - }, - }) - }) - await page.getByRole('button', { name: 'Compare versions…' }).click() - await page.getByRole('dialog').getByRole('button', { name: 'Compare', exact: true }).click() - await expect(page.locator('.viewer-fallback-route-source')).toHaveCount(1) - await expect(page).toHaveURL(/compareFrom=/) - const comparisonUrl = page.url() - const comparisonPair = new URL(comparisonUrl) - await page.goBack() - await expect(page.locator('.version-comparison-dialog')).toHaveCount(0) - await page.evaluate(() => { - Object.defineProperty(window.OCA.Text, 'createMarkdownContentComparison', { - configurable: true, - value: undefined, - }) - }) - await page.evaluate((url) => { - window.history.pushState({}, '', url) - window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state })) - }, comparisonUrl) - const viewerPanes = page.locator('.viewer__modal .viewer__comparison > *') - // `or` statement can be removed once we only support Nextcloud 36+ - .or(page.locator('#viewer .viewer--split > .viewer__file-wrapper')) - .filter({ visible: true }) - await expect(viewerPanes).toHaveCount(2) - await expect(page.locator('.version-comparison-dialog')).toHaveCount(0) - const retained = new URL(page.url()) - expect([retained.searchParams.get('compareFrom'), retained.searchParams.get('compareTo')]).toEqual([ - comparisonPair.searchParams.get('compareFrom'), - comparisonPair.searchParams.get('compareTo'), - ]) - await expect(viewerPanes.nth(0)).toContainText('Historical comparison bytes') - await expect(viewerPanes.nth(1)).toContainText('Current comparison bytes') - assertNoFailures() - }) -}) diff --git a/playwright/support/helpers/versionComparisonFixtures.ts b/playwright/support/helpers/versionComparisonFixtures.ts deleted file mode 100644 index eff7b406d4..0000000000 --- a/playwright/support/helpers/versionComparisonFixtures.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -/* eslint-disable jsdoc/require-jsdoc */ - -export const VERSION_COMPARISON_FIXTURE_PREFIX = 'vc-e2e-' - -export type VersionComparisonAccount = { - userId: string - password: string - language: string -} - -type OccOptions = { - env?: string[] - failOnError?: boolean -} - -type OccRunner = (command: string[], options?: OccOptions) => Promise - -export function assertVersionComparisonFixtureName(name: string): void { - if (!name.startsWith(VERSION_COMPARISON_FIXTURE_PREFIX) || name.length === VERSION_COMPARISON_FIXTURE_PREFIX.length) { - throw new Error(`Fixture mutations require a ${VERSION_COMPARISON_FIXTURE_PREFIX} name`) - } -} - -export function createVersionComparisonAccount( - role: string, - suffix: string | number, - passwordFactory: () => string, -): VersionComparisonAccount { - const userId = `${VERSION_COMPARISON_FIXTURE_PREFIX}${role}-${suffix}` - assertVersionComparisonFixtureName(userId) - return { - userId, - password: `VersionComparison-${passwordFactory()}-Strong`, - language: 'en', - } -} - -export async function provisionVersionComparisonUser(account: VersionComparisonAccount, runOcc: OccRunner): Promise { - assertVersionComparisonFixtureName(account.userId) - await runOcc(['user:delete', account.userId], { failOnError: false }) - await runOcc([ - 'user:add', - '--password-from-env', - `--display-name=${account.userId}`, - account.userId, - ], { env: [`OC_PASS=${account.password}`] }) -} - -export async function deleteVersionComparisonUser(userId: string, runOcc: OccRunner): Promise { - assertVersionComparisonFixtureName(userId) - await runOcc(['user:delete', userId]) -} diff --git a/src/components/CollectiveContainer.vue b/src/components/CollectiveContainer.vue index 8d621645a6..8c89977124 100644 --- a/src/components/CollectiveContainer.vue +++ b/src/components/CollectiveContainer.vue @@ -55,7 +55,6 @@ import { useSharesStore } from '../stores/shares.js' import { useTagsStore } from '../stores/tags.js' import { useVersionsStore } from '../stores/versions.js' import displayError from '../util/displayError.js' -import { canonicalPathRoute } from '../util/versionComparisonRoute.js' export default { name: 'CollectiveContainer', @@ -164,10 +163,13 @@ export default { if (this.currentCollective && this.isLandingPage && this.$route.path !== this.currentCollectivePath) { - this.$router.replace(canonicalPathRoute(this.$route, this.currentCollectivePath)) + this.$router.replace({ path: this.currentCollectivePath, hash: document.location.hash }) } else if (this.currentPage - && this.$route.path !== this.currentPagePath) { - this.$router.replace(canonicalPathRoute(this.$route, this.currentPagePath)) + && this.$route.fullPath !== this.currentPagePath) { + this.$router.replace({ + path: this.currentPagePath, + hash: document.location.hash, + }) } }, }, diff --git a/src/components/Page/PageTitleBar.vue b/src/components/Page/PageTitleBar.vue index 15baa0ce2c..38ffd88e4e 100644 --- a/src/components/Page/PageTitleBar.vue +++ b/src/components/Page/PageTitleBar.vue @@ -135,7 +135,6 @@ import pageMixin from '../../mixins/pageMixin.js' import { useCollectivesStore } from '../../stores/collectives.js' import { usePagesStore } from '../../stores/pages.js' import { useRootStore } from '../../stores/root.js' -import { canonicalPathRoute } from '../../util/versionComparisonRoute.js' export default { name: 'PageTitleBar', @@ -297,7 +296,7 @@ export default { // The resulting title may be different due to sanitizing this.newTitle = this.currentPage.title this.getPages(false) - await this.$router.replace(canonicalPathRoute(this.$route, this.currentPagePath)) + await this.$router.replace(this.currentPagePath) } catch (e) { console.error(e) showError(t('collectives', 'Could not rename the page')) diff --git a/src/components/Page/TextEditor.vue b/src/components/Page/TextEditor.vue index d46c11db6f..8b598b9d21 100644 --- a/src/components/Page/TextEditor.vue +++ b/src/components/Page/TextEditor.vue @@ -41,7 +41,6 @@ import { useCirclesStore } from '../../stores/circles.js' import { useCollectivesStore } from '../../stores/collectives.js' import { usePagesStore } from '../../stores/pages.js' import { useRootStore } from '../../stores/root.js' -import { useVersionsStore } from '../../stores/versions.js' import { encodeAttachmentFilename } from '../../util/attachmentFilename.ts' export default { @@ -77,7 +76,6 @@ export default { data() { return { textEditWatcher: null, - unregisterCurrentSnapshotPreparer: null, } }, @@ -116,7 +114,6 @@ export default { }, async mounted() { - this.unregisterCurrentSnapshotPreparer = this.registerCurrentSnapshotPreparer(() => this.prepareCurrentEditorSnapshot()) const readerPromise = this.setupReader(this.currentPage) const editorPromise = this.setupEditor() const pageContentPromise = this.getPageContent() @@ -141,7 +138,6 @@ export default { }, beforeUnmount() { - this.unregisterCurrentSnapshotPreparer?.() unsubscribe('collectives:attachment:removeReferences', this.removeAttachmentReferences) unsubscribe('collectives:attachment:replaceFilename', this.replaceAttachmentFilename) unsubscribe('collectives:attachment:insert', this.insertAttachment) @@ -152,7 +148,6 @@ export default { t, ...mapActions(useRootStore, ['load', 'done']), - ...mapActions(useVersionsStore, ['getVersions', 'registerCurrentSnapshotPreparer']), ...mapActions(usePagesStore, ['setTextEdit', 'setTextPreview', 'touchPage']), ...mapActions(useCirclesStore, ['getCircleMembers']), @@ -203,13 +198,6 @@ export default { return this.editor.save() }, - async prepareCurrentEditorSnapshot() { - if (this.isTextEdit && this.editor && await this.saveEditor() !== true) { - throw new Error('Could not save the current page before comparison.') - } - return this.getVersions(this.currentPage.id) - }, - async stopEdit() { // switch back to edit if there's no content if (!this.pageContent?.trim()) { diff --git a/src/components/PageSidebar.vue b/src/components/PageSidebar.vue index 968ce22586..1e2301a12f 100644 --- a/src/components/PageSidebar.vue +++ b/src/components/PageSidebar.vue @@ -55,7 +55,6 @@ diff --git a/src/components/PageSidebar/SidebarTabVersions.vue b/src/components/PageSidebar/SidebarTabVersions.vue index 49987ce719..fbe7202599 100644 --- a/src/components/PageSidebar/SidebarTabVersions.vue +++ b/src/components/PageSidebar/SidebarTabVersions.vue @@ -24,16 +24,6 @@
- - - {{ t('collectives', 'Compare versions…') }} -
@@ -68,36 +58,24 @@ v-model:open="showVersionLabelForm" :versionLabel="editedVersion.label" @labelUpdate="onLabelUpdate" /> - -
- - diff --git a/src/components/PageSidebar/VersionComparisonDialog.vue b/src/components/PageSidebar/VersionComparisonDialog.vue deleted file mode 100644 index 19b947afcd..0000000000 --- a/src/components/PageSidebar/VersionComparisonDialog.vue +++ /dev/null @@ -1,625 +0,0 @@ - - - - - - - diff --git a/src/components/PageSidebar/VersionEntry.vue b/src/components/PageSidebar/VersionEntry.vue index 1d3dbfa65f..993682a8ab 100644 --- a/src/components/PageSidebar/VersionEntry.vue +++ b/src/components/PageSidebar/VersionEntry.vue @@ -7,7 +7,6 @@ + @click="$emit('compare')"> diff --git a/src/stores/versions.js b/src/stores/versions.js index 83c8283ca3..99c70d65d4 100644 --- a/src/stores/versions.js +++ b/src/stores/versions.js @@ -13,12 +13,8 @@ import * as davApi from '../apis/dav/index.js' import { useCollectivesStore } from './collectives.js' import { usePagesStore } from './pages.js' -const preparers = new WeakMap() -const requests = new WeakMap() - export const useVersionsStore = defineStore('versions', { state: () => ({ - loadedPageId: null, selectedVersion: null, versions: [], }), @@ -48,42 +44,20 @@ export const useVersionsStore = defineStore('versions', { }, actions: { - registerCurrentSnapshotPreparer(preparer) { - preparers.set(this, preparer) - return () => { - if (preparers.get(this) === preparer) { - preparers.delete(this) - } - } - }, - - async prepareCurrentSnapshot() { - return preparers.get(this)?.() - }, - selectVersion(version) { this.selectedVersion = version }, - getVersions(pageId) { - const task = Promise.resolve() - .then(() => davApi.getVersions(pageId)) - .then((response) => { - const versions = response.data - .filter(({ mime }) => mime !== '') - .map((version) => this.formatVersion(version, pageId)) - const currentSnapshot = versions.reduce((latest, version) => !latest || version.mtime > latest.mtime ? version : latest, null) - if (currentSnapshot) { - currentSnapshot.isCurrentSnapshot = true - } - if (requests.get(this) === task) { - this.versions = versions - this.loadedPageId = pageId - } - return versions - }) - requests.set(this, task) - return task + async getVersions(pageId) { + const response = await davApi.getVersions(pageId) + // Another page may have been opened meanwhile + if (pageId !== usePagesStore().currentPageId) { + return + } + this.versions = response.data + // filter out root + .filter(({ mime }) => mime !== '') + .map((version) => this.formatVersion(version, pageId)) }, formatVersion(version, pageId) { diff --git a/src/tests/playwrightConfig.spec.ts b/src/tests/playwrightConfig.spec.ts deleted file mode 100644 index fe8b7e6d4d..0000000000 --- a/src/tests/playwrightConfig.spec.ts +++ /dev/null @@ -1,65 +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 originalBaseURL = process.env.baseURL -const originalSemanticE2E = process.env.COLLECTIVES_SEMANTIC_E2E - -afterEach(() => { - if (originalBaseURL === undefined) { - delete process.env.baseURL - } else { - process.env.baseURL = originalBaseURL - } - if (originalSemanticE2E === undefined) { - delete process.env.COLLECTIVES_SEMANTIC_E2E - } else { - process.env.COLLECTIVES_SEMANTIC_E2E = originalSemanticE2E - } -}) - -async function loadConfig(baseURL: string | undefined, semanticE2E = false) { - if (baseURL === undefined) { - delete process.env.baseURL - } else { - process.env.baseURL = baseURL - } - process.env.COLLECTIVES_SEMANTIC_E2E = semanticE2E ? '1' : '0' - vi.resetModules() - return (await import('../../playwright.config.ts')).default -} - -describe('Playwright server selection', () => { - it.each([undefined, ''])('uses the managed server for %s baseURL', async (baseURL) => { - const config = await loadConfig(baseURL) - - expect(config.use?.baseURL).toBe('http://localhost:8089/index.php/') - expect(config.webServer).toBeDefined() - }) - - it('uses a non-empty external baseURL without the managed server', async () => { - const config = await loadConfig('https://nextcloud.local/index.php/') - - expect(config.use?.baseURL).toBe('https://nextcloud.local/index.php/') - expect(config.webServer).toBeUndefined() - }) - - it('runs only the tagged Viewer fallback in the stable project', async () => { - const config = await loadConfig(undefined) - const project = config.projects?.find(({ name }) => name === 'comparison-viewer-chromium') - - expect(project?.grep).toEqual(/@viewer-fallback/) - expect(config.projects?.map(({ name }) => name)).not.toContain('comparison-chromium') - }) - - it('runs semantic comparison in Chromium without the Viewer fallback', async () => { - const config = await loadConfig(undefined, true) - const projects = config.projects?.filter(({ name }) => name?.startsWith('comparison-') && name !== 'comparison-viewer-chromium') - - expect(projects?.map(({ name }) => name)).toEqual(['comparison-chromium']) - expect(projects?.map(({ grepInvert }) => grepInvert)).toEqual([/@viewer-fallback/]) - }) -}) diff --git a/src/tests/util/boundedSnapshotBody.spec.js b/src/tests/util/boundedSnapshotBody.spec.js deleted file mode 100644 index efc4bf1858..0000000000 --- a/src/tests/util/boundedSnapshotBody.spec.js +++ /dev/null @@ -1,152 +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' -import { - ComparisonSnapshotEncodingError, - ComparisonSnapshotLimitError, - loadBoundedSnapshotBody, - loadBoundedSnapshotPreview, - loadVersionComparisonSnapshots, - VERSION_COMPARISON_LIMITS, -} from '../../util/versionComparison.js' - -afterEach(() => vi.unstubAllGlobals()) - -function responseWithChunks(chunks, { status = 200 } = {}) { - const cancel = vi.fn() - let index = 0 - return { - cancel, - response: { - ok: status >= 200 && status < 300, - status, - body: { - getReader: () => ({ - cancel, - read: vi.fn(async () => index < chunks.length - ? { done: false, value: chunks[index++] } - : { done: true }), - }), - }, - }, - } -} - -describe('bounded snapshot body acquisition', () => { - it.each([ - ['complete body', loadBoundedSnapshotBody], - ['bounded preview', loadBoundedSnapshotPreview], - ])('cache-busts the current %s but keeps historical snapshots cacheable', async (_name, load) => { - vi.stubGlobal('window', { location: { href: 'https://nextcloud.local/apps/collectives/Atlas/Page' } }) - const currentResponse = responseWithChunks([new TextEncoder().encode('fresh')]).response - const historicalResponse = responseWithChunks([new TextEncoder().encode('old')]).response - const fetchCurrent = vi.fn().mockResolvedValue(currentResponse) - const fetchHistorical = vi.fn().mockResolvedValue(historicalResponse) - - await load({ kind: 'current', size: 5, url: '/current?file=7' }, fetchCurrent) - await load({ kind: 'historical', size: 3, url: '/historical?file=7' }, fetchHistorical) - - const currentUrl = new URL(fetchCurrent.mock.calls[0][0]) - expect(currentUrl.pathname).toBe('/current') - expect(currentUrl.searchParams.get('file')).toBe('7') - expect(currentUrl.searchParams.get('timestamp')).toMatch(/^\d{13}$/) - expect(fetchHistorical.mock.calls[0][0]).toBe('/historical?file=7') - }) - - it('AUD-08 cancels an incorrectly advertised body at the byte ceiling', async () => { - const first = new Uint8Array(VERSION_COMPARISON_LIMITS.maximumSnapshotBytes) - const overflow = new Uint8Array([0x41]) - const { response, cancel } = responseWithChunks([first, overflow]) - const fetchSnapshot = vi.fn().mockResolvedValue(response) - const snapshot = { kind: 'historical', size: 1, url: '/snapshot' } - - await expect(loadBoundedSnapshotBody(snapshot, fetchSnapshot)) - .rejects.toEqual(expect.objectContaining({ - name: 'ComparisonSnapshotLimitError', - snapshots: [snapshot], - })) - expect(cancel).toHaveBeenCalledOnce() - expect(fetchSnapshot).toHaveBeenCalledWith('/snapshot', expect.objectContaining({ - credentials: 'same-origin', - })) - }) - - it('accepts a streamed body exactly at the byte ceiling', async () => { - const bytes = new TextEncoder().encode('bounded') - const { response, cancel } = responseWithChunks([bytes]) - - await expect(loadBoundedSnapshotBody( - { kind: 'historical', size: bytes.byteLength, url: '/snapshot' }, - vi.fn().mockResolvedValue(response), - )).resolves.toEqual({ - byteLength: bytes.byteLength, - content: 'bounded', - }) - expect(cancel).not.toHaveBeenCalled() - }) - - it('fails closed when a complete snapshot contains malformed UTF-8', async () => { - const { response, cancel } = responseWithChunks([new Uint8Array([0x66, 0x80, 0x6f])]) - - await expect(loadBoundedSnapshotBody( - { kind: 'historical', size: 3, url: '/snapshot' }, - vi.fn().mockResolvedValue(response), - )).rejects.toBeInstanceOf(ComparisonSnapshotEncodingError) - expect(cancel).toHaveBeenCalledOnce() - }) - - it('preserves an HTTP status for existing permission and expiry classification', async () => { - const { response } = responseWithChunks([], { status: 403 }) - - await expect(loadBoundedSnapshotBody( - { kind: 'historical', size: 1, url: '/snapshot' }, - vi.fn().mockResolvedValue(response), - )).rejects.toEqual(expect.objectContaining({ response: { status: 403 } })) - }) - - it('uses the caller cancellation signal', async () => { - const controller = new AbortController() - const { response } = responseWithChunks([new Uint8Array()]) - const fetchSnapshot = vi.fn().mockResolvedValue(response) - - await loadBoundedSnapshotBody( - { kind: 'historical', size: 0, url: '/snapshot' }, - fetchSnapshot, - controller.signal, - ) - expect(fetchSnapshot).toHaveBeenCalledWith('/snapshot', expect.objectContaining({ - signal: controller.signal, - })) - }) - - it('unwraps measured bodies through atomic pair loading', async () => { - const earlier = { kind: 'historical', size: 1, url: '/earlier' } - const later = { kind: 'current', size: 1, url: '/later' } - - await expect(loadVersionComparisonSnapshots( - { earlier, later }, - async ({ url }) => ({ byteLength: 1, content: url }), - )).resolves.toEqual({ - afterContent: '/later', - beforeContent: '/earlier', - }) - }) - - it('preserves a streamed size-limit failure through atomic pair loading', async () => { - const limited = { kind: 'historical', size: 1, url: '/limited' } - const current = { kind: 'current', size: 1, url: '/current' } - - await expect(loadVersionComparisonSnapshots( - { earlier: limited, later: current }, - (snapshot) => snapshot === limited - ? Promise.reject(new ComparisonSnapshotLimitError([snapshot])) - : Promise.resolve('current'), - )).rejects.toEqual(expect.objectContaining({ - name: 'ComparisonSnapshotLimitError', - snapshots: [limited], - })) - }) -}) diff --git a/src/tests/util/versionComparison.spec.js b/src/tests/util/versionComparison.spec.js deleted file mode 100644 index 1f85c0baec..0000000000 --- a/src/tests/util/versionComparison.spec.js +++ /dev/null @@ -1,509 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { describe, expect, it, vi } from 'vitest' -import { - classifyComparisonSnapshotError, - ComparisonSnapshotEncodingError, - ComparisonSnapshotError, - ComparisonSnapshotLimitError, - createComparisonRequestManager, - createVersionComparisonState, - CurrentSnapshotPreparationError, - isVersionComparisonCancellation, - loadBoundedSnapshotPreview, - loadVersionComparisonSnapshots, - normalizeVersionComparisonPair, - prepareVersionComparisonPair, - selectVersionComparisonRenderer, - VERSION_COMPARISON_LIMITS, - VersionComparisonSnapshotCache, -} from '../../util/versionComparison.js' - -const current = { fileId: 7, label: '', mtime: 3000, size: 10, source: '/current', url: '/current' } -const old = { fileVersion: '1', label: 'Old', mtime: 1000, size: 10, source: '/old', url: '/old' } -const middle = { fileVersion: '2', label: 'Middle', mtime: 2000, size: 10, source: '/middle', url: '/middle' } - -function measured(content, byteLength = new TextEncoder().encode(content).byteLength) { - return { byteLength, content } -} - -function state(versions = [old, middle]) { - return createVersionComparisonState(current, versions) -} - -function pair(first, second) { - const { options } = state() - const option = (version) => options.find(({ fileInfo }) => fileInfo === version) - return normalizeVersionComparisonPair(option(first), option(second)) -} - -describe('version comparison pair model', () => { - it.each([ - [false, vi.fn(), true, 'semantic'], - [true, vi.fn(), true, 'semantic'], - [false, undefined, true, 'viewer'], - [true, undefined, true, 'unsupported'], - [false, undefined, false, 'unsupported'], - ])('selects supported renderers without breaking the desktop Viewer fallback', (isMobile, comparisonFactory, viewerCanCompare, expected) => { - expect(selectVersionComparisonRenderer({ comparisonFactory, viewerCanCompare, isMobile })).toBe(expected) - }) - - it('creates stable current and historical identities in newest-first selector order', () => { - expect(state().options.map(({ key }) => key)).toEqual(['current', 'version:2', 'version:1']) - }) - - it('quarantines invalid and duplicate historical identities', () => { - const result = state([ - { ...old, fileVersion: '' }, - { ...old, fileVersion: 'same' }, - { ...middle, fileVersion: 'same' }, - middle, - ]) - expect(result.options.map(({ key }) => key)).toEqual(['current', 'version:2']) - expect(result.quarantinedCount).toBe(3) - const usableRouteIds = result.options.flatMap(({ routeId, routeAliases = [] }) => [routeId, ...routeAliases]) - expect(result.ambiguousRouteIds.some((routeId) => usableRouteIds.includes(routeId))).toBe(false) - }) - - it('quarantines a historical identity that matches the current snapshot', () => { - const currentSnapshot = { - ...current, - fileVersion: 'same', - isCurrentSnapshot: true, - } - const result = state([ - { ...old, fileVersion: 'same' }, - currentSnapshot, - ]) - - expect(result.options.map(({ key }) => key)).toEqual(['current']) - expect(result.options[0].routeId).toBe('current:same') - expect(result.quarantinedCount).toBe(1) - expect(result.ambiguousRouteIds).toEqual(['version:same']) - }) - - it('C01 normalizes current and historical snapshots by chronology and identity', () => { - expect(pair(current, old)).toMatchObject({ - earlier: { key: 'version:1', kind: 'historical', label: 'Old', mtime: 1000 }, - later: { key: 'current', kind: 'current', mtime: 3000 }, - }) - }) - - it('C02 loads both immutable historical snapshots in chronological order', async () => { - const comparisonPair = pair(middle, old) - const fetchSnapshot = vi.fn(async ({ url }) => measured(`body:${url}`)) - - await expect(loadVersionComparisonSnapshots(comparisonPair, fetchSnapshot)).resolves.toEqual({ - afterContent: 'body:/middle', - beforeContent: 'body:/old', - }) - expect(fetchSnapshot.mock.calls.map(([snapshot]) => snapshot.key)) - .toEqual(['version:1', 'version:2']) - }) - - it('C03 normalizes reversed selectors without swapping visible labels', () => { - expect(pair(middle, old)).toMatchObject({ - earlier: { key: 'version:1', label: 'Old', mtime: 1000 }, - later: { key: 'version:2', label: 'Middle', mtime: 2000 }, - }) - }) - - it('C04 rejects an identical pair before comparison starts', () => { - const option = state().options[0] - expect(() => normalizeVersionComparisonPair(option, option)).toThrow('distinct') - }) -}) - -describe('atomic current snapshot freshness', () => { - it('AUD-03 binds current bytes to the committed DAV snapshot resource', () => { - const committed = { - ...middle, - etag: '"committed"', - fileVersion: '4', - isCurrentSnapshot: true, - mtime: 4000, - source: '/versions/4', - } - const [currentOption] = createVersionComparisonState( - { ...current, mtime: 4000, source: '/mutable-live' }, - [old, committed], - ).options - - expect(currentOption).toMatchObject({ - etag: '"committed"', - routeId: 'current:4', - url: '/versions/4', - }) - }) - - it('AUD-03 does not pair a committed identity with the mutable live URL', () => { - const committedWithoutSource = { - ...middle, - fileVersion: '4', - isCurrentSnapshot: true, - mtime: 4000, - source: undefined, - url: undefined, - } - const [currentOption] = createVersionComparisonState( - { ...current, etag: '"mutable"', mtime: 4000, size: 99, source: '/mutable-live', url: '/mutable-live' }, - [old, committedWithoutSource], - ).options - - expect(currentOption).toMatchObject({ - routeId: 'current:4', - size: committedWithoutSource.size, - }) - expect(currentOption.etag).toBeUndefined() - expect(currentOption.url).toBeUndefined() - }) - - it('AUD-03 resolves identity and bytes from the committed preparation result', async () => { - const stalePair = pair(current, old) - const committedPair = { - earlier: stalePair.earlier, - later: { ...stalePair.later, mtime: 4000, routeId: 'current:4', url: '/current-4' }, - } - const prepare = vi.fn().mockResolvedValue(committedPair) - const resolvePair = vi.fn((prepared) => prepared ?? stalePair) - - await expect(prepareVersionComparisonPair(stalePair, prepare, resolvePair)) - .resolves.toBe(committedPair) - expect(resolvePair).toHaveBeenCalledWith(committedPair) - }) - - it('AUD-03 keeps committed route and bytes stable across a later save race', async () => { - const stalePair = pair(current, old) - const liveCurrent = { ...current, mtime: 4000, source: '/mutable-live-4' } - const committed = { - ...middle, - fileVersion: '4', - isCurrentSnapshot: true, - mtime: 4000, - source: '/versions/4', - } - const committedPair = await prepareVersionComparisonPair( - stalePair, - vi.fn().mockResolvedValue([old, committed]), - (versions) => { - const options = createVersionComparisonState(liveCurrent, versions).options - return normalizeVersionComparisonPair( - options.find(({ key }) => key === stalePair.earlier.key), - options.find(({ key }) => key === stalePair.later.key), - ) - }, - ) - - liveCurrent.mtime = 5000 - liveCurrent.source = '/mutable-live-5' - committed.source = '/versions/5' - const contents = await loadVersionComparisonSnapshots( - committedPair, - async ({ url }) => measured(`bytes:${url}`), - ) - - expect(committedPair.later).toMatchObject({ - routeId: 'current:4', - url: '/versions/4', - }) - expect(contents.afterContent).toBe('bytes:/versions/4') - }) - - it('C08 rejects failed current preparation before resolving a stale pair', async () => { - const stalePair = pair(current, old) - const failure = new Error('save failed') - const prepare = vi.fn().mockRejectedValue(failure) - const resolvePair = vi.fn() - - await expect(prepareVersionComparisonPair(stalePair, prepare, resolvePair)) - .rejects.toMatchObject({ cause: failure }) - expect(resolvePair).not.toHaveBeenCalled() - }) - - it('reports a missing current preparer as a typed preparation failure', async () => { - const stalePair = pair(current, old) - const resolvePair = vi.fn() - - await expect(prepareVersionComparisonPair( - stalePair, - vi.fn().mockResolvedValue(undefined), - resolvePair, - )).rejects.toBeInstanceOf(CurrentSnapshotPreparationError) - expect(resolvePair).not.toHaveBeenCalled() - }) - - it('rebinds the current route identity after preparation refreshes versions', async () => { - const stalePair = pair(current, old) - const freshCurrent = { ...current, mtime: 4000 } - const freshVersions = [{ ...old }, { ...middle, fileVersion: '4', mtime: 4000, isCurrentSnapshot: true }] - const prepare = vi.fn().mockResolvedValue(freshVersions) - const resolvePair = vi.fn((versions) => { - const options = createVersionComparisonState(freshCurrent, versions).options - return normalizeVersionComparisonPair( - options.find(({ key }) => key === stalePair.earlier.key), - options.find(({ key }) => key === stalePair.later.key), - ) - }) - - const refreshedPair = await prepareVersionComparisonPair(stalePair, prepare, resolvePair) - - expect(prepare).toHaveBeenCalledOnce() - expect(resolvePair).toHaveBeenCalledOnce() - expect(refreshedPair.later.routeId).toBe('current:4') - }) -}) - -describe('atomic loading, cancellation, and cache', () => { - it.each([ - ['unknown', undefined], - ['negative', -1], - ['over the ceiling', VERSION_COMPARISON_LIMITS.maximumSnapshotBytes + 1], - ])('AUD-08 rejects %s snapshot size before either body request', async (_name, size) => { - const comparisonPair = pair(old, middle) - comparisonPair.earlier = { ...comparisonPair.earlier, size } - const fetchSnapshot = vi.fn() - - await expect(loadVersionComparisonSnapshots(comparisonPair, fetchSnapshot)) - .rejects.toBeInstanceOf(ComparisonSnapshotLimitError) - expect(fetchSnapshot).not.toHaveBeenCalled() - }) - - it.each([ - VERSION_COMPARISON_LIMITS.maximumSnapshotBytes - 1, - VERSION_COMPARISON_LIMITS.maximumSnapshotBytes, - ])('AUD-08 accepts trusted metadata at %i bytes', async (size) => { - const comparisonPair = pair(old, middle) - comparisonPair.earlier = { ...comparisonPair.earlier, size } - comparisonPair.later = { ...comparisonPair.later, size } - const fetchSnapshot = vi.fn().mockResolvedValue(measured('bounded')) - - await expect(loadVersionComparisonSnapshots(comparisonPair, fetchSnapshot)).resolves.toBeDefined() - expect(fetchSnapshot).toHaveBeenCalledTimes(2) - }) - - it('AUD-08 bounds a preview when the server ignores Range', async () => { - const cancel = vi.fn() - const fetchPreview = vi.fn().mockResolvedValue({ - body: { - getReader: () => ({ - cancel, - read: vi.fn() - .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode('x'.repeat(VERSION_COMPARISON_LIMITS.maximumPreviewBytes + 100)) }) - .mockResolvedValueOnce({ done: true }), - }), - }, - ok: true, - }) - - const preview = await loadBoundedSnapshotPreview(state().options[1], fetchPreview) - - expect(new TextEncoder().encode(preview.content).byteLength).toBeLessThanOrEqual(VERSION_COMPARISON_LIMITS.maximumPreviewBytes) - expect([...preview.content]).toHaveLength(VERSION_COMPARISON_LIMITS.maximumPreviewCharacters) - expect(preview.truncated).toBe(true) - expect(cancel).toHaveBeenCalledOnce() - expect(fetchPreview.mock.calls[0][1].headers.Range).toBe(`bytes=0-${VERSION_COMPARISON_LIMITS.maximumPreviewBytes - 1}`) - }) - - it('AUD-08 keeps UTF-8 preview text and DOM characters bounded', async () => { - const bytes = new TextEncoder().encode('é'.repeat(VERSION_COMPARISON_LIMITS.maximumPreviewCharacters + 10)) - const fetchPreview = vi.fn().mockResolvedValue(new Response(bytes)) - - const preview = await loadBoundedSnapshotPreview(state().options[1], fetchPreview) - - expect(preview.content).not.toContain('\uFFFD') - expect([...preview.content]).toHaveLength(VERSION_COMPARISON_LIMITS.maximumPreviewCharacters) - expect(preview.truncated).toBe(true) - }) - - it('AUD-08 rejects interior malformed UTF-8 without consuming the remaining preview', async () => { - const cancel = vi.fn() - const read = vi.fn() - .mockResolvedValueOnce({ done: false, value: Uint8Array.from([0x61]) }) - .mockResolvedValueOnce({ done: false, value: Uint8Array.from([0x80]) }) - .mockResolvedValueOnce({ done: false, value: Uint8Array.from([0x62]) }) - const fetchPreview = vi.fn().mockResolvedValue({ - body: { getReader: () => ({ cancel, read }) }, - ok: true, - }) - - await expect(loadBoundedSnapshotPreview(state().options[1], fetchPreview)) - .rejects.toThrow() - expect(cancel).toHaveBeenCalledOnce() - expect(read).toHaveBeenCalledTimes(2) - }) - - it.each([ - [1, [0xf0]], - [2, [0xf0, 0x9f]], - [3, [0xf0, 0x9f, 0x98]], - ])('AUD-08 drops only an incomplete trailing UTF-8 sequence split after %i bytes', async (_length, suffix) => { - const bytes = Uint8Array.from([0x6f, 0x6b, ...suffix]) - const fetchPreview = vi.fn().mockResolvedValue(new Response(bytes)) - - await expect(loadBoundedSnapshotPreview(state().options[1], fetchPreview)).resolves.toEqual({ - content: 'ok', - truncated: true, - }) - }) - - it.each([ - [[{ response: { status: 403 } }], 'permission-one'], - [[{ response: { status: 403 } }, { response: { status: 403 } }], 'permission-two'], - [[{ response: { status: 404 } }], 'expired-one'], - [[{ response: { status: 410 } }, { response: { status: 404 } }], 'expired-two'], - [[{ response: { status: 403 } }, { response: { status: 404 } }], 'network'], - [[{ response: { status: 404 } }, new Error('offline')], 'network'], - [[new ComparisonSnapshotEncodingError()], 'encoding'], - [[new Error('offline')], 'network'], - ])('classifies snapshot failures for actionable messages', (reasons, expected) => { - const error = { reasons } - expect(classifyComparisonSnapshotError(error)).toBe(expected) - }) - - it('distinguishes cancellation from a network failure', () => { - expect(isVersionComparisonCancellation({ name: 'AbortError' })).toBe(true) - expect(isVersionComparisonCancellation({ name: 'CanceledError' })).toBe(true) - expect(isVersionComparisonCancellation(new Error('offline'))).toBe(false) - expect(isVersionComparisonCancellation({ __CANCEL__: true })).toBe(true) - }) - - it('loads both immutable snapshots and publishes neither when one or both fail', async () => { - const comparisonPair = pair(old, middle) - const fetchSnapshot = vi.fn(async ({ url }) => { - if (url === '/old') { - throw new Error('expired') - } - return measured(url) - }) - const error = await loadVersionComparisonSnapshots(comparisonPair, fetchSnapshot).catch((reason) => reason) - expect(error).toBeInstanceOf(ComparisonSnapshotError) - expect(error.reasons).toHaveLength(1) - expect(fetchSnapshot).toHaveBeenCalledTimes(2) - }) - - it('C05 reuses a successful historical body without another DAV load', async () => { - const cache = new VersionComparisonSnapshotCache() - const historical = state().options[1] - const fetchSnapshot = vi.fn(async ({ kind }) => measured(`${kind}-${fetchSnapshot.mock.calls.length}`)) - - expect(await cache.load(historical, fetchSnapshot)).toBe(await cache.load(historical, fetchSnapshot)) - expect(fetchSnapshot).toHaveBeenCalledOnce() - }) - - it('C06 reloads the current snapshot instead of serving a stale cache entry', async () => { - const cache = new VersionComparisonSnapshotCache() - const currentSnapshot = state().options[0] - const fetchSnapshot = vi.fn(async () => measured(`current-${fetchSnapshot.mock.calls.length}`)) - - await cache.load(currentSnapshot, fetchSnapshot) - await cache.load(currentSnapshot, fetchSnapshot) - expect(fetchSnapshot).toHaveBeenCalledTimes(2) - }) - - it('C11 does not cache a failed or aborted historical completion before retry', async () => { - const cache = new VersionComparisonSnapshotCache() - const historical = state().options[1] - const failed = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(measured('ok')) - await expect(cache.load(historical, failed)).rejects.toThrow('offline') - await expect(cache.load(historical, failed)).resolves.toEqual(measured('ok')) - cache.clear() - const controller = new AbortController() - const aborted = vi.fn(async () => { - controller.abort() - return measured('stale') - }) - await cache.load(historical, aborted, controller.signal) - await cache.load(historical, aborted) - expect(aborted).toHaveBeenCalledTimes(2) - }) - - it('AUD-15 enforces entry, UTF-8 byte, identity, recency, and clear boundaries', async () => { - const cache = new VersionComparisonSnapshotCache({ maximumBytes: 8, maximumEntries: 2 }) - const snapshots = [ - { ...state().options[1], fileVersion: 'a', url: '/a' }, - { ...state().options[1], fileVersion: 'b', url: '/b' }, - { ...state().options[1], fileVersion: 'c', url: '/c' }, - ] - const fetchSnapshot = vi.fn(async ({ fileVersion }) => measured(({ a: 'éé', b: 'bbbb', c: 'cccc' })[fileVersion])) - - await cache.load(snapshots[0], fetchSnapshot) - await cache.load(snapshots[1], fetchSnapshot) - await cache.load(snapshots[0], fetchSnapshot) - await cache.load(snapshots[2], fetchSnapshot) - await cache.load(snapshots[1], fetchSnapshot) - expect(fetchSnapshot).toHaveBeenCalledTimes(4) - - const sameVersionDifferentUrl = { ...snapshots[0], url: '/different' } - await cache.load(sameVersionDifferentUrl, fetchSnapshot) - expect(fetchSnapshot).toHaveBeenCalledTimes(5) - - cache.clear() - await cache.load(sameVersionDifferentUrl, fetchSnapshot) - expect(fetchSnapshot).toHaveBeenCalledTimes(6) - }) - - it('AUD-15 reuses the bounded loader byte measurement when caching', async () => { - const cache = new VersionComparisonSnapshotCache({ maximumBytes: 4, maximumEntries: 1 }) - const historical = state().options[1] - const fetchSnapshot = vi.fn().mockResolvedValue({ - byteLength: 5, - content: 'x', - }) - - await expect(cache.load(historical, fetchSnapshot)).resolves.toEqual({ byteLength: 5, content: 'x' }) - await cache.load(historical, fetchSnapshot) - - expect(fetchSnapshot).toHaveBeenCalledTimes(2) - }) - - it.each([ - ['two-byte characters', 'éé', 3], - ['an astral character', '😀', 3], - ['an unpaired surrogate', '\ud800', 2], - ])('AUD-15 does not cache %s above the UTF-8 byte budget', async (_name, content, maximumBytes) => { - const cache = new VersionComparisonSnapshotCache({ maximumBytes, maximumEntries: 2 }) - const historical = state().options[1] - const fetchSnapshot = vi.fn().mockResolvedValue(measured(content)) - - await cache.load(historical, fetchSnapshot) - await cache.load(historical, fetchSnapshot) - expect(fetchSnapshot).toHaveBeenCalledTimes(2) - }) - - it('C09 aborts a superseded request and prevents its generation from publishing', () => { - const manager = createComparisonRequestManager() - const first = manager.begin() - const second = manager.begin() - expect(first.signal.aborted).toBe(true) - expect(manager.isCurrent(first.generation)).toBe(false) - expect(manager.isCurrent(second.generation)).toBe(true) - }) - - it('C10 allows only the latest rapid pair generation to publish', () => { - const manager = createComparisonRequestManager() - const first = manager.begin() - const second = manager.begin() - const published = [] - - if (manager.isCurrent(first.generation)) { - published.push('stale') - } - if (manager.isCurrent(second.generation)) { - published.push('latest') - } - - expect(published).toEqual(['latest']) - }) - - it('cancels the active comparison request', () => { - const manager = createComparisonRequestManager() - const active = manager.begin() - - manager.cancel() - expect(active.signal.aborted).toBe(true) - expect(manager.isCurrent(active.generation)).toBe(false) - }) -}) diff --git a/src/tests/util/versionComparisonE2eFixtures.spec.ts b/src/tests/util/versionComparisonE2eFixtures.spec.ts deleted file mode 100644 index 6443a0b972..0000000000 --- a/src/tests/util/versionComparisonE2eFixtures.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { describe, expect, it, vi } from 'vitest' -import { - assertVersionComparisonFixtureName, - createVersionComparisonAccount, - deleteVersionComparisonUser, - provisionVersionComparisonUser, -} from '../../../playwright/support/helpers/versionComparisonFixtures.ts' - -describe('version comparison persistent-stack fixtures', () => { - it.each([ - 'owner', - 'vc-owner', - 'vc-e2e', - 'other-vc-e2e-owner', - ])('rejects non-namespaced fixture mutations for %s', (name) => { - expect(() => assertVersionComparisonFixtureName(name)).toThrow('vc-e2e-') - }) - - it('creates a namespaced account with an opaque password', () => { - const account = createVersionComparisonAccount('pw-owner', 7, () => 'opaque-secret') - - expect(account).toEqual({ - userId: 'vc-e2e-pw-owner-7', - password: 'VersionComparison-opaque-secret-Strong', - language: 'en', - }) - }) - - it('keeps the longest browser worker account within the server limit', () => { - const account = createVersionComparisonAccount('pw-owner-12345678-comparison-viewer-chromium', 0, () => 'opaque-secret') - - expect(account.userId.length).toBeLessThanOrEqual(64) - }) - - it('provisions only an exact namespaced user with password-from-env', async () => { - const runOcc = vi.fn().mockResolvedValue({ exitCode: 0 }) - const account = createVersionComparisonAccount('owner', 'cypress', () => 'opaque-secret') - - await provisionVersionComparisonUser(account, runOcc) - - expect(runOcc).toHaveBeenNthCalledWith(1, ['user:delete', account.userId], { failOnError: false }) - expect(runOcc).toHaveBeenNthCalledWith(2, [ - 'user:add', - '--password-from-env', - `--display-name=${account.userId}`, - account.userId, - ], { env: ['OC_PASS=VersionComparison-opaque-secret-Strong'] }) - }) - - it('fails closed before provisioning a non-namespaced user', async () => { - const runOcc = vi.fn() - - await expect(provisionVersionComparisonUser({ - userId: 'developer', - password: 'opaque-secret', - language: 'en', - }, runOcc)).rejects.toThrow('vc-e2e-') - expect(runOcc).not.toHaveBeenCalled() - }) - - it('deletes only the exact namespaced user and surfaces cleanup failures', async () => { - const runOcc = vi.fn().mockResolvedValue({ exitCode: 0 }) - - await deleteVersionComparisonUser('vc-e2e-owner-cypress', runOcc) - - expect(runOcc).toHaveBeenCalledExactlyOnceWith(['user:delete', 'vc-e2e-owner-cypress']) - }) - - it('fails closed before deleting a non-namespaced user', async () => { - const runOcc = vi.fn() - - await expect(deleteVersionComparisonUser('developer', runOcc)).rejects.toThrow('vc-e2e-') - expect(runOcc).not.toHaveBeenCalled() - }) -}) diff --git a/src/tests/util/versionComparisonRoute.spec.js b/src/tests/util/versionComparisonRoute.spec.js deleted file mode 100644 index 5812aa27c5..0000000000 --- a/src/tests/util/versionComparisonRoute.spec.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest' -import { - canonicalPathRoute, - claimVersionComparisonRouteWarning, - MAX_COMPARISON_ID_LENGTH, - parseVersionComparisonRoute, - rejectedVersionComparisonRoute, - resolveVersionComparisonRoute, - withoutVersionComparisonRoute, - withVersionComparisonRoute, -} from '../../util/versionComparisonRoute.js' - -const route = { - path: '/Atlas/Page', - query: { fileId: '42', view: 'grid' }, - hash: '#rollout', -} - -describe('canonical version comparison routes', () => { - it('warns once per rejected history entry and again after re-entry', () => { - const query = { ...route.query, compareFrom: 'version:1', compareTo: 'current:2' } - const rejected = rejectedVersionComparisonRoute({ ...route, query }, {}) - - expect(claimVersionComparisonRouteWarning(query, {})).toBe(true) - expect(claimVersionComparisonRouteWarning(query, rejected.state)).toBe(false) - expect(claimVersionComparisonRouteWarning(query, {})).toBe(true) - expect(rejected.query).toEqual(route.query) - }) - - it('R01 round-trips an exact ordered current/historical or historical/historical pair', () => { - for (const [from, to] of [ - ['version:1', 'current:2'], - ['version:1', 'version:2'], - ]) { - const location = withVersionComparisonRoute(route, from, to) - expect(parseVersionComparisonRoute(location.query)).toEqual({ kind: 'valid', from, to }) - } - }) - - it.each([ - ['one value', { compareFrom: '1' }], - ['empty value', { compareFrom: '', compareTo: 'current' }], - ['identical value', { compareFrom: '1', compareTo: '1' }], - ['duplicate array', { compareFrom: ['1', '2'], compareTo: 'current' }], - ['slash', { compareFrom: 'versions/1', compareTo: 'current' }], - ['backslash', { compareFrom: 'versions\\1', compareTo: 'current' }], - ['control character', { compareFrom: 'version\n1', compareTo: 'current' }], - ['overlong identity', { compareFrom: 'x'.repeat(MAX_COMPARISON_ID_LENGTH + 1), compareTo: 'current' }], - ])('fails closed for %s without accepting an ambiguous route', (_name, query) => { - expect(parseVersionComparisonRoute(query)).toEqual({ kind: 'invalid' }) - }) - - it('distinguishes an absent route', () => { - expect(parseVersionComparisonRoute(route.query)).toEqual({ kind: 'absent' }) - }) - - it('R10 preserves unrelated query, hash, and path while closing a managed route', () => { - const compared = withVersionComparisonRoute(route, 'version:1', 'current:2') - expect(withoutVersionComparisonRoute(compared)).toEqual(route) - }) - - it('preserves the exact query pair while a renamed page path is canonicalized', () => { - const compared = withVersionComparisonRoute(route, 'version:1', 'current:2') - expect(canonicalPathRoute(compared, '/Atlas/Page-renamed')).toEqual({ - path: '/Atlas/Page-renamed', - query: compared.query, - hash: route.hash, - }) - }) - - it('R07 reports one missing version without substitution', () => { - const options = [ - { routeId: 'current:2', routeAliases: ['current'] }, - { routeId: 'version:1' }, - ] - expect(resolveVersionComparisonRoute(options, { from: 'missing', to: 'current' }).missing) - .toEqual(['missing']) - }) - - it('R08 reports two missing versions without substitution', () => { - const options = [ - { routeId: 'current:2', routeAliases: ['current'] }, - { routeId: 'version:1' }, - ] - expect(resolveVersionComparisonRoute(options, { from: 'missing-1', to: 'missing-2' }).missing) - .toEqual(['missing-1', 'missing-2']) - }) - - it.each([ - ['canonical ID then alias', 'current:2', 'current'], - ['alias then canonical ID', 'current', 'current:2'], - ])('AUD-20 rejects %s resolving to the same semantic snapshot', (_name, from, to) => { - const current = { key: 'current', routeId: 'current:2', routeAliases: ['current'] } - const resolved = resolveVersionComparisonRoute( - [current, { key: 'version:1', routeId: 'version:1' }], - { from, to }, - ) - - expect(resolved).toEqual({ - first: current, - second: current, - missing: [], - invalid: true, - }) - }) - - it('rejects rebuilt options with the same semantic key', () => { - const canonical = { key: 'current', routeId: 'current:2' } - const alias = { key: 'current', routeId: 'current' } - - expect(resolveVersionComparisonRoute( - [canonical, alias], - { from: 'current:2', to: 'current' }, - )).toEqual({ - first: canonical, - second: alias, - missing: [], - invalid: true, - }) - }) -}) diff --git a/src/tests/util/versionsStore.spec.js b/src/tests/util/versionsStore.spec.js deleted file mode 100644 index bc7f3e19b2..0000000000 --- a/src/tests/util/versionsStore.spec.js +++ /dev/null @@ -1,136 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as davApi from '../../apis/dav/index.js' -import { useVersionsStore } from '../../stores/versions.js' - -vi.mock('../../apis/dav/index.js', () => ({ - deleteVersion: vi.fn(), - getVersions: vi.fn(), - restoreVersion: vi.fn(), -})) -vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn(), showSuccess: vi.fn() })) -vi.mock('@nextcloud/l10n', () => ({ t: (_app, message) => message })) -vi.mock('@nextcloud/moment', () => ({ - default: (value) => ({ - format: () => String(value), - unix: () => Math.floor(new Date(value).getTime() / 1000), - }), -})) -vi.mock('@nextcloud/paths', () => ({ encodePath: (value) => value, join: (...parts) => parts.join('') })) -vi.mock('@nextcloud/router', () => ({ generateRemoteUrl: () => '/remote.php/dav' })) -vi.mock('../../stores/collectives.js', () => ({ useCollectivesStore: () => ({ currentCollectiveCanEdit: true }) })) -vi.mock('../../stores/pages.js', () => ({ - usePagesStore: () => ({ - currentPage: { id: 7, title: 'Page' }, - pageDavPath: () => '', - }), -})) - -function deferred() { - let reject - let resolve - const promise = new Promise((resolvePromise, rejectPromise) => { - reject = rejectPromise - resolve = resolvePromise - }) - return { promise, reject, resolve } -} - -function response(fileVersion, lastmod) { - return { - data: [{ - basename: fileVersion, - filename: `/versions/7/${fileVersion}`, - lastmod, - mime: 'text/markdown', - props: { getetag: `"${fileVersion}"`, 'version-author': 'user', 'version-label': '' }, - size: 10, - type: 'file', - }], - } -} - -describe('versions metadata generations', () => { - beforeEach(() => { - setActivePinia(createPinia()) - vi.restoreAllMocks() - }) - - it('publishes only the latest A to B to A request without redirecting stale results', async () => { - const firstA = deferred() - const b = deferred() - const latestA = deferred() - vi.mocked(davApi.getVersions) - .mockReturnValueOnce(firstA.promise) - .mockReturnValueOnce(b.promise) - .mockReturnValueOnce(latestA.promise) - const store = useVersionsStore() - - const firstRequest = store.getVersions(7) - const secondRequest = store.getVersions(8) - const latestRequest = store.getVersions(7) - firstA.resolve(response('100', '2026-08-29T10:00:00Z')) - b.resolve(response('200', '2026-08-29T10:01:00Z')) - - await expect(firstRequest).resolves.toEqual([expect.objectContaining({ fileVersion: '100' })]) - await expect(secondRequest).resolves.toEqual([expect.objectContaining({ fileVersion: '200' })]) - expect(store.versions).toEqual([]) - latestA.resolve(response('300', '2026-08-29T10:02:00Z')) - - const latestResult = await latestRequest - expect(latestResult).toEqual(store.versions) - expect(store.versions.map(({ fileVersion }) => fileVersion)).toEqual(['300']) - expect(store.loadedPageId).toBe(7) - }) - - it('returns the stale transport failure instead of a synthetic superseded error', async () => { - const stale = deferred() - const current = deferred() - vi.mocked(davApi.getVersions) - .mockReturnValueOnce(stale.promise) - .mockReturnValueOnce(current.promise) - const store = useVersionsStore() - - const staleRequest = store.getVersions(7) - const currentRequest = store.getVersions(8) - stale.reject(new Error('stale transport failed')) - current.resolve(response('200', '2026-08-29T10:01:00Z')) - - await expect(staleRequest).rejects.toThrow('stale transport failed') - await currentRequest - expect(store.loadedPageId).toBe(8) - }) - - it('keeps the current snapshot preparer outside reactive Pinia state', async () => { - const store = useVersionsStore() - const prepare = vi.fn().mockResolvedValue('prepared') - const unregister = store.registerCurrentSnapshotPreparer(prepare) - - expect(Object.hasOwn(store.$state, 'currentSnapshotPreparer')).toBe(false) - await expect(store.prepareCurrentSnapshot()).resolves.toBe('prepared') - unregister() - await expect(store.prepareCurrentSnapshot()).resolves.toBeUndefined() - }) - - it.each([ - ['restore', 'restoreVersion'], - ['delete', 'deleteVersion'], - ])('handles a metadata refresh failure after %s', async (_operation, action) => { - vi.mocked(davApi[action]).mockResolvedValue() - const store = useVersionsStore() - vi.spyOn(store, 'getVersions').mockRejectedValue(new Error('refresh failed')) - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const version = { basename: 'Initial version', fileId: 7, fileVersion: '100' } - - await expect(store[action](version)).resolves.toBeUndefined() - - expect(davApi[action]).toHaveBeenCalledWith(7, '100') - expect(store.getVersions).toHaveBeenCalledWith(7) - expect(consoleError).toHaveBeenCalledWith('Failed to refresh page versions', expect.any(Error)) - }) -}) diff --git a/src/tests/util/viewer.spec.ts b/src/tests/util/viewer.spec.ts new file mode 100644 index 0000000000..a975c95913 --- /dev/null +++ b/src/tests/util/viewer.spec.ts @@ -0,0 +1,73 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { canCompare, getViewer } from '@nextcloud/viewer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { compareVersions } from '../../util/viewer.ts' + +vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => ({ uid: 'alice' }) })) +vi.mock('@nextcloud/files', () => ({ + File: class { + constructor(options: object) { + Object.assign(this, options) + } + }, + Permission: { READ: 1 }, +})) +vi.mock('@nextcloud/files/dav', () => ({ getRootPath: () => '/files/alice' })) +vi.mock('@nextcloud/viewer', () => ({ + canCompare: vi.fn(), + canView: vi.fn(), + getViewer: vi.fn(), +})) + +const current = { + fileId: 7, + filename: '/files/alice/Collectives/Garden/Page.md', + source: 'https://cloud.example/remote.php/dav/files/alice/Collectives/Garden/Page.md', + mime: 'text/markdown', + size: 20, + mtime: 2_000_000, + etag: 'current', +} + +const version = { + ...current, + filename: '/versions/alice/versions/7/1000', + source: 'https://cloud.example/remote.php/dav/versions/alice/versions/7/1000', + mtime: 1_000_000, + etag: 'old', +} + +describe('compareVersions', () => { + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + }) + + it('compares in the viewer library', async () => { + const compare = vi.fn() + vi.mocked(canCompare).mockReturnValue(true) + vi.mocked(getViewer).mockReturnValue({ compare } as never) + + await compareVersions(current, version) + + expect(compare).toHaveBeenCalledWith( + expect.objectContaining({ source: current.source }), + expect.objectContaining({ source: version.source, root: '/versions/alice' }), + ) + }) + + it('falls back to the Viewer app', async () => { + const compare = vi.fn() + vi.mocked(canCompare).mockReturnValue(false) + vi.stubGlobal('window', { OCA: { Viewer: { registerHandler: vi.fn(), compare } } }) + + await compareVersions(current, version) + + expect(getViewer).not.toHaveBeenCalled() + expect(compare).toHaveBeenCalledWith(current, version) + }) +}) diff --git a/src/util/versionComparison.js b/src/util/versionComparison.js deleted file mode 100644 index bddb1b1b25..0000000000 --- a/src/util/versionComparison.js +++ /dev/null @@ -1,351 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { isValidComparisonId } from './versionComparisonRoute.js' - -/* eslint-disable jsdoc/require-jsdoc */ - -export const CURRENT_VERSION_KEY = 'current' - -export const VERSION_COMPARISON_LIMITS = Object.freeze({ - maximumCachedBytes: 4_000_000, - maximumCachedEntries: 2, - maximumPreviewBytes: 16_384, - maximumPreviewCharacters: 4_096, - maximumSnapshotBytes: 2_000_000, -}) -const LIMITS = VERSION_COMPARISON_LIMITS - -const CURRENT_KIND = 'current' -const CURRENT_ROUTE_NAMESPACE = 'current' -const HISTORICAL_KIND = 'historical' -const HISTORICAL_ROUTE_NAMESPACE = 'version' - -export function selectVersionComparisonRenderer({ comparisonFactory, viewerCanCompare, isMobile }) { - return typeof comparisonFactory === 'function' - ? 'semantic' - : !isMobile && viewerCanCompare ? 'viewer' : 'unsupported' -} - -export function createVersionComparisonState(currentVersion, versions) { - const currentSnapshot = versions.find(({ isCurrentSnapshot }) => isCurrentSnapshot) - ?? versions.find(({ mtime }) => mtime === currentVersion.mtime) - const currentMtime = currentSnapshot?.mtime ?? currentVersion.mtime - const currentSnapshotId = validIdentity(currentSnapshot?.fileVersion) ? currentSnapshot.fileVersion : String(Math.floor(currentMtime / 1000)) - const currentRouteId = routeId(CURRENT_ROUTE_NAMESPACE, currentSnapshotId) - const current = { - etag: currentSnapshot ? currentSnapshot.etag : currentVersion.etag, - fileInfo: currentVersion, - key: CURRENT_VERSION_KEY, - kind: CURRENT_KIND, - mtime: currentMtime, - routeAliases: [CURRENT_VERSION_KEY], - routeId: currentRouteId, - size: currentSnapshot ? currentSnapshot.size : currentVersion.size, - url: currentSnapshot ? currentSnapshot.source ?? currentSnapshot.url : currentVersion.source ?? currentVersion.url, - } - const candidates = versions.filter((version) => version !== currentSnapshot) - const identityCounts = new Map() - for (const { fileVersion } of versions) { - if (validIdentity(fileVersion)) { - identityCounts.set(fileVersion, (identityCounts.get(fileVersion) ?? 0) + 1) - } - } - const duplicateIdentities = new Set([...identityCounts].filter(([, count]) => count > 1).map(([fileVersion]) => fileVersion)) - let quarantinedCount = 0 - const historical = candidates - .flatMap((version) => { - const fileVersion = version.fileVersion - if (!validIdentity(fileVersion) || duplicateIdentities.has(fileVersion)) { - quarantinedCount++ - return [] - } - const route = routeId(HISTORICAL_ROUTE_NAMESPACE, fileVersion) - const previousCurrentRouteId = routeId(CURRENT_ROUTE_NAMESPACE, fileVersion) - return [{ - fileInfo: version, - fileVersion, - key: route, - kind: HISTORICAL_KIND, - label: version.label, - mtime: version.mtime, - routeAliases: previousCurrentRouteId === currentRouteId ? [] : [previousCurrentRouteId], - routeId: route, - size: version.size, - url: version.source ?? version.url, - }] - }) - .toSorted(compare) - - return { - ambiguousRouteIds: [...duplicateIdentities] - .flatMap((fileVersion) => [routeId(HISTORICAL_ROUTE_NAMESPACE, fileVersion), routeId(CURRENT_ROUTE_NAMESPACE, fileVersion)]) - .filter((routeId) => routeId !== currentRouteId), - options: [current, ...historical.reverse()], - quarantinedCount, - } -} - -function routeId(namespace, identity) { - return `${namespace}:${identity}` -} - -function validIdentity(fileVersion) { - return isValidComparisonId(fileVersion) - && isValidComparisonId(routeId(HISTORICAL_ROUTE_NAMESPACE, fileVersion)) - && isValidComparisonId(routeId(CURRENT_ROUTE_NAMESPACE, fileVersion)) -} - -export function normalizeVersionComparisonPair(first, second) { - if (!first || !second || first.key === second.key) { - throw new TypeError('Two distinct versions are required') - } - return compare(first, second) <= 0 ? { earlier: first, later: second } : { earlier: second, later: first } -} - -function compare(first, second) { - if (first.mtime !== second.mtime) { - return first.mtime - second.mtime - } - if (first.kind !== second.kind) { - return first.kind === 'current' ? 1 : -1 - } - return first.key < second.key ? -1 : first.key > second.key ? 1 : 0 -} - -export class ComparisonSnapshotError extends Error { - constructor(before, after) { - super('One or more comparison snapshots could not be loaded') - this.name = new.target.name - this.reasons = [before, after].filter(({ status }) => status === 'rejected').map(({ reason }) => reason) - } -} - -export class ComparisonSnapshotEncodingError extends Error { - constructor(cause) { - super('Comparison snapshot contains invalid UTF-8', { cause }) - this.name = new.target.name - } -} - -export class ComparisonSnapshotLimitError extends Error { - constructor(snapshots) { - super('One or more comparison snapshots exceed the comparison size limit') - this.name = new.target.name - this.snapshots = snapshots - } -} - -export function classifyComparisonSnapshotError(error) { - if (error.reasons.length > 0 && error.reasons.every((reason) => reason instanceof ComparisonSnapshotEncodingError)) { - return 'encoding' - } - const statuses = error.reasons.map((reason) => reason?.response?.status) - const count = error.reasons.length === 2 ? 'two' : 'one' - if (statuses.every((status) => status === 403)) { - return `permission-${count}` - } - if (statuses.every((status) => status === 404 || status === 410)) { - return `expired-${count}` - } - return 'network' -} - -export function isVersionComparisonCancellation(error) { - return error?.name === 'AbortError' || error?.name === 'CanceledError' || error?.__CANCEL__ === true -} - -export class CurrentSnapshotPreparationError extends Error { - constructor(cause) { - super('Current editor bytes could not be prepared', { cause }) - this.name = new.target.name - } -} - -export async function prepareVersionComparisonPair(pair, prepareCurrentSnapshot, resolvePair) { - if (pair.earlier.kind !== 'current' && pair.later.kind !== 'current') { - return pair - } - let prepared - try { - prepared = await prepareCurrentSnapshot() - } catch (error) { - throw new CurrentSnapshotPreparationError(error) - } - if (prepared === undefined) { - throw new CurrentSnapshotPreparationError() - } - return resolvePair(prepared) -} - -export async function loadVersionComparisonSnapshots(pair, fetchSnapshot, signal) { - const unboundedSnapshots = [pair.earlier, pair.later].filter(({ size }) => !Number.isSafeInteger(size) || size < 0 || size > LIMITS.maximumSnapshotBytes) - if (unboundedSnapshots.length > 0) { - throw new ComparisonSnapshotLimitError(unboundedSnapshots) - } - const load = (snapshot) => Promise.resolve().then(() => fetchSnapshot(snapshot, signal)) - const [before, after] = await Promise.allSettled([load(pair.earlier), load(pair.later)]) - const limitedSnapshots = [before, after].filter(({ status, reason }) => status === 'rejected' && reason instanceof ComparisonSnapshotLimitError).flatMap(({ reason }) => reason.snapshots) - if (limitedSnapshots.length > 0) { - throw new ComparisonSnapshotLimitError(limitedSnapshots) - } - if (before.status === 'rejected' || after.status === 'rejected') { - throw new ComparisonSnapshotError(before, after) - } - return { afterContent: contentOf(after.value), beforeContent: contentOf(before.value) } -} - -function contentOf(loaded) { - if (typeof loaded?.content === 'string' && Number.isSafeInteger(loaded.byteLength) && loaded.byteLength >= 0) { - return loaded.content - } - throw new TypeError('Snapshot loader must return measured content') -} - -export async function loadBoundedSnapshotBody(snapshot, fetchSnapshot = fetch, signal) { - return readSnapshot(snapshot, fetchSnapshot, signal, false) -} - -export async function loadBoundedSnapshotPreview(snapshot, fetchPreview = fetch, signal) { - return readSnapshot(snapshot, fetchPreview, signal, true) -} - -async function readSnapshot(snapshot, fetchSnapshot, signal, preview) { - const limit = preview ? LIMITS.maximumPreviewBytes : LIMITS.maximumSnapshotBytes - let url = snapshot.url - if (snapshot.kind === CURRENT_KIND) { - const currentUrl = new URL(url, window.location.href) - currentUrl.searchParams.set('timestamp', Date.now()) - url = currentUrl.href - } - const response = await fetchSnapshot(url, { credentials: 'same-origin', ...(preview ? { headers: { Range: `bytes=0-${limit - 1}` } } : {}), signal }) - if (!response.ok) { - const error = new Error(`Snapshot request failed with status ${response.status}`) - error.response = { status: response.status } - throw error - } - const length = Number(response.headers?.get?.('content-length')) - if (!preview && Number.isFinite(length) && length > limit) { - await response.body?.cancel?.() - throw new ComparisonSnapshotLimitError([snapshot]) - } - const reader = response.body?.getReader() - if (!reader) { - throw new Error('Snapshot response is not streamable') - } - - const decoder = new TextDecoder('utf-8', { fatal: true }) - let bytes = 0 - const chunks = [] - let truncated = preview && snapshot.size > limit - while (bytes < limit) { - const { done, value } = await reader.read() - if (done) { - break - } - const copied = Math.min(value.byteLength, limit - bytes) - if (!preview && copied < value.byteLength) { - await reader.cancel() - throw new ComparisonSnapshotLimitError([snapshot]) - } - try { - chunks.push(decoder.decode(value.subarray(0, copied), { stream: true })) - } catch (error) { - await reader.cancel() - throw preview ? error : new ComparisonSnapshotEncodingError(error) - } - bytes += copied - if (preview && (copied < value.byteLength || bytes === limit)) { - truncated = true - await reader.cancel() - break - } - } - if (!preview && bytes === limit) { - const { done } = await reader.read() - if (!done) { - await reader.cancel() - throw new ComparisonSnapshotLimitError([snapshot]) - } - } - - try { - chunks.push(decoder.decode()) - } catch (error) { - if (!preview) { - throw new ComparisonSnapshotEncodingError(error) - } - truncated = true - } - let content = chunks.join('') - if (!preview) { - return { byteLength: bytes, content } - } - const chars = Array.from(content) - if (chars.length > LIMITS.maximumPreviewCharacters) { - content = chars.slice(0, LIMITS.maximumPreviewCharacters).join('') - truncated = true - } - return { content, truncated } -} - -export class VersionComparisonSnapshotCache { - constructor({ maximumBytes = LIMITS.maximumCachedBytes, maximumEntries = LIMITS.maximumCachedEntries } = {}) { - this.entries = new Map() - this.byteLimit = maximumBytes - this.entryLimit = maximumEntries - this.bytes = 0 - } - - async load(snapshot, fetchSnapshot, signal) { - const key = JSON.stringify([snapshot.fileInfo?.id ?? snapshot.fileInfo?.fileId ?? snapshot.fileId, snapshot.fileVersion, snapshot.fileInfo?.etag ?? snapshot.etag, snapshot.url]) - if (snapshot.kind === 'historical' && this.entries.has(key)) { - const cached = this.entries.get(key) - this.entries.delete(key) - this.entries.set(key, cached) - return cached - } - - const loaded = await fetchSnapshot(snapshot, signal) - contentOf(loaded) - if (snapshot.kind === 'historical' && !signal?.aborted) { - const bytes = loaded.byteLength - if (bytes <= this.byteLimit && this.entryLimit > 0) { - while (this.entries.size >= this.entryLimit || this.bytes + bytes > this.byteLimit) { - const oldestKey = this.entries.keys().next().value - const oldest = this.entries.get(oldestKey) - this.entries.delete(oldestKey) - this.bytes -= oldest.byteLength - } - this.entries.set(key, loaded) - this.bytes += bytes - } - } - return loaded - } - - clear() { - this.entries.clear() - this.bytes = 0 - } -} - -export function createComparisonRequestManager() { - let controller = null - let generation = 0 - return { - begin() { - controller?.abort() - controller = new AbortController() - return { generation: ++generation, signal: controller.signal } - }, - isCurrent: (candidate) => candidate === generation, - cancel() { - controller?.abort() - controller = null - generation++ - }, - } -} diff --git a/src/util/versionComparisonRoute.js b/src/util/versionComparisonRoute.js deleted file mode 100644 index d5c4bc8ba4..0000000000 --- a/src/util/versionComparisonRoute.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -export const COMPARE_FROM_QUERY = 'compareFrom' -export const COMPARE_TO_QUERY = 'compareTo' -export const COMPARISON_HISTORY_STATE = 'collectivesVersionComparison' -export const COMPARISON_WARNING_STATE = 'collectivesVersionComparisonWarning' -export const MAX_COMPARISON_ID_LENGTH = 255 - -/* eslint-disable jsdoc/require-jsdoc */ - -const warningKey = (query) => JSON.stringify([query[COMPARE_FROM_QUERY], query[COMPARE_TO_QUERY]]) -const routeLocation = (route, query = route.query) => ({ path: route.path, query, hash: route.hash }) -function invalidComparisonIdCharacter(character) { - const codePoint = character.codePointAt(0) - return character === '/' || character === '\\' || codePoint <= 31 || codePoint === 127 -} - -export function claimVersionComparisonRouteWarning(query, state = window.history.state) { - return state?.[COMPARISON_WARNING_STATE] !== warningKey(query) -} - -export function rejectedVersionComparisonRoute(route) { - return { - ...withoutVersionComparisonRoute(route), - state: { - [COMPARISON_WARNING_STATE]: warningKey(route.query), - }, - } -} - -export function canonicalPathRoute(route, path) { - return routeLocation({ ...route, path }) -} - -export function parseVersionComparisonRoute(query) { - const hasFrom = Object.hasOwn(query, COMPARE_FROM_QUERY) - const hasTo = Object.hasOwn(query, COMPARE_TO_QUERY) - if (!hasFrom && !hasTo) { - return { kind: 'absent' } - } - if (!hasFrom || !hasTo) { - return { kind: 'invalid' } - } - - const from = query[COMPARE_FROM_QUERY] - const to = query[COMPARE_TO_QUERY] - if (!isValidComparisonId(from) - || !isValidComparisonId(to) - || from === to) { - return { kind: 'invalid' } - } - - return { kind: 'valid', from, to } -} - -export function withVersionComparisonRoute(route, from, to) { - return routeLocation(route, { - ...route.query, - [COMPARE_FROM_QUERY]: from, - [COMPARE_TO_QUERY]: to, - }) -} - -export function withoutVersionComparisonRoute(route) { - const query = { ...route.query } - delete query[COMPARE_FROM_QUERY] - delete query[COMPARE_TO_QUERY] - return routeLocation(route, query) -} - -export function resolveVersionComparisonRoute(options, requested) { - const byRouteId = new Map(options.flatMap((option) => [option.routeId, ...(option.routeAliases ?? [])].map((routeId) => [routeId, option]))) - const first = byRouteId.get(requested.from) ?? null - const second = byRouteId.get(requested.to) ?? null - const missing = [requested.from, requested.to].filter((routeId) => !byRouteId.has(routeId)) - return { first, second, missing, invalid: Boolean(first && second && (first === second || (first.key !== undefined && first.key === second.key))) } -} - -export function isValidComparisonId(value) { - return typeof value === 'string' - && value.length > 0 - && value.length <= MAX_COMPARISON_ID_LENGTH - && ![...value].some(invalidComparisonIdCharacter) -} diff --git a/src/util/viewer.ts b/src/util/viewer.ts index 173658f5be..9bc46343ac 100644 --- a/src/util/viewer.ts +++ b/src/util/viewer.ts @@ -99,16 +99,6 @@ export function legacyCanView(mime: string): boolean { return legacyViewer()?.availableHandlers.some(({ mimes }) => mimes.includes(mime)) ?? false } -/** - * Whether the viewer library or the Viewer app can compare versions of the page - * Needed for Nextcloud 35 and below. Can be removed once we only support Nextcloud 36+ - * - * @param current - The current version of the page - */ -export function canCompareVersions(current: VersionInfo): boolean { - return canCompare(versionNode(current)) || typeof legacyViewer()?.compare === 'function' -} - /** * Show two versions of the page side by side, preferably in the viewer library * Needed for Nextcloud 35 and below. Can be removed once we only support Nextcloud 36+ From 09cb539767470c8f916590fc18bd068e96de18d9 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 19 Oct 2026 12:18:57 +0200 Subject: [PATCH 2/9] feat(versions): select two versions to compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The versions tab gets a "Compare versions…" button that opens a dialog to pick any two versions of the page. The viewer then shows their differences, using the earlier version as base. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Jonas --- .../PageSidebar/SidebarTabVersions.vue | 45 +++++++++ .../PageSidebar/VersionCompareDialog.vue | 91 +++++++++++++++++++ src/tests/util/viewer.spec.ts | 13 +++ src/util/viewer.ts | 10 +- 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/components/PageSidebar/VersionCompareDialog.vue diff --git a/src/components/PageSidebar/SidebarTabVersions.vue b/src/components/PageSidebar/SidebarTabVersions.vue index fbe7202599..8c1d0b2fd4 100644 --- a/src/components/PageSidebar/SidebarTabVersions.vue +++ b/src/components/PageSidebar/SidebarTabVersions.vue @@ -24,6 +24,16 @@
+ + + {{ t('collectives', 'Compare versions…') }} +
    + +
+ + diff --git a/src/components/PageSidebar/VersionCompareDialog.vue b/src/components/PageSidebar/VersionCompareDialog.vue new file mode 100644 index 0000000000..c7224e0df3 --- /dev/null +++ b/src/components/PageSidebar/VersionCompareDialog.vue @@ -0,0 +1,91 @@ + + + + + + + diff --git a/src/tests/util/viewer.spec.ts b/src/tests/util/viewer.spec.ts index a975c95913..e9db743b48 100644 --- a/src/tests/util/viewer.spec.ts +++ b/src/tests/util/viewer.spec.ts @@ -60,6 +60,19 @@ describe('compareVersions', () => { ) }) + it('uses the earlier version as base regardless of the order', async () => { + const compare = vi.fn() + vi.mocked(canCompare).mockReturnValue(true) + vi.mocked(getViewer).mockReturnValue({ compare } as never) + + await compareVersions(version, current) + + expect(compare).toHaveBeenCalledWith( + expect.objectContaining({ source: current.source }), + expect.objectContaining({ source: version.source }), + ) + }) + it('falls back to the Viewer app', async () => { const compare = vi.fn() vi.mocked(canCompare).mockReturnValue(false) diff --git a/src/util/viewer.ts b/src/util/viewer.ts index 9bc46343ac..3375f31349 100644 --- a/src/util/viewer.ts +++ b/src/util/viewer.ts @@ -101,12 +101,16 @@ export function legacyCanView(mime: string): boolean { /** * Show two versions of the page side by side, preferably in the viewer library + * + * The earlier version is the base of the comparison, regardless of the order + * of the arguments. * Needed for Nextcloud 35 and below. Can be removed once we only support Nextcloud 36+ * - * @param later - The later version - * @param earlier - The earlier version + * @param first - One of the versions + * @param second - The other version */ -export async function compareVersions(later: VersionInfo, earlier: VersionInfo): Promise { +export async function compareVersions(first: VersionInfo, second: VersionInfo): Promise { + const [earlier, later] = first.mtime <= second.mtime ? [first, second] : [second, first] if (canCompare(versionNode(later))) { await getViewer().compare(versionNode(later), versionNode(earlier)) return From 8550875d9164ac066ee8d787c2cee0bfe1f0bf18 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 19 Oct 2026 12:20:49 +0200 Subject: [PATCH 3/9] test(versions): migrate page versions tests to Playwright Also covers comparing two selected versions. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Jonas --- cypress/e2e/page-versions.spec.js | 136 --------------------------- playwright/e2e/page-versions.spec.ts | 114 ++++++++++++++++++++++ 2 files changed, 114 insertions(+), 136 deletions(-) delete mode 100644 cypress/e2e/page-versions.spec.js create mode 100644 playwright/e2e/page-versions.spec.ts diff --git a/cypress/e2e/page-versions.spec.js b/cypress/e2e/page-versions.spec.js deleted file mode 100644 index c20435a8eb..0000000000 --- a/cypress/e2e/page-versions.spec.js +++ /dev/null @@ -1,136 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -describe('Page versions', function() { - before(function() { - cy.loginAs('bob') - cy.deleteAndSeedCollective('Versions Collective') - .seedPage('Page', '', 'Readme.md') - - // A new version will not be created if the changes occur within less than one second of each other. - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent('Versions Collective/Page.md', 'v1') - .wait(1100) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent('Versions Collective/Page.md', 'v2') - .wait(1100) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.seedPageContent('Versions Collective/Page.md', 'v3') - .wait(1100) - cy.seedPageContent('Versions Collective/Page.md', 'v4') - }) - - beforeEach(function() { - cy.loginAs('bob') - cy.visit('/apps/collectives/Versions Collective/Page') - - cy.get('button.page-sidebar-button').click() - cy.get('#tab-button-versions').click() - }) - - it('Lists versions', function() { - cy.getReadOnlyEditor() - .should('contain', 'v4') - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('have.length', 4) - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('contain', 'Current version') - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('contain', 'Initial version') - }) - - it('Open initial and current version', function() { - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .contains('Initial version') - .click() - - cy.get('.page-title-container') - .find('.title-version') - .should('be.visible') - cy.getReadOnlyEditor() - .should('contain', 'v1') - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .contains('Current version') - .click() - - cy.get('.page-title-container') - .find('.title-version') - .should('not.exist') - cy.getReadOnlyEditor() - .should('contain', 'v4') - }) - - it('Add label to version', function() { - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(1) - .find('.list-item-content__actions') - .click() - - cy.clickMenuButton('Name this version') - - cy.get('.version-label-modal input[type="text"]') - .type('v3{enter}') - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('contain', 'v3') - }) - - it('Compare initial and current version', function() { - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(3) - .find('.list-item-content__actions') - .click() - - cy.clickMenuButton('Compare with current version') - - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal, #viewer') - .should('contain', 'v1') - .and('contain', 'v4') - - cy.window().then((window) => window.OCA.Viewer.close()) - // Second locator can be removed once we only support Nextcloud 36+ - cy.get('.viewer__modal, #viewer[role="dialog"]').should('not.exist') - }) - - it('Restore initial version', function() { - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(3) - .find('.list-item-content__actions') - .click() - - cy.intercept('MOVE', '**/dav/versions/**').as('moveVersion') - cy.clickMenuButton('Restore version') - cy.wait('@moveVersion') - cy.contains('[role="status"]', 'Restored').should('be.visible') - - // Check the restored file content via WebDAV instead of reloading: - // reloading makes the text client replay its cached (pre-restore) - // document state into the open session, overwriting the restore. - cy.request('/csrftoken').then(({ body }) => { - cy.request({ - url: `${Cypress.expose('baseUrl')}/remote.php/webdav/.Collectives/${encodeURIComponent('Versions Collective')}/Page.md`, - headers: { requesttoken: body.token }, - }).its('body').should('contain', 'v1') - }) - }) - - it('Delete version', function() { - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .eq(2) - .find('.list-item-content__actions') - .click() - - cy.intercept('MOVE', '**/dav/versions/**').as('moveVersion') - cy.clickMenuButton('Delete version') - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('have.length', 3) - }) -}) diff --git a/playwright/e2e/page-versions.spec.ts b/playwright/e2e/page-versions.spec.ts new file mode 100644 index 0000000000..019c382554 --- /dev/null +++ b/playwright/e2e/page-versions.spec.ts @@ -0,0 +1,114 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' +import type { CollectivePage } from '../support/fixtures/CollectivePage.ts' + +import { expect, mergeTests } from '@playwright/test' +import { test as createCollectivesTest } from '../support/fixtures/create-collectives.ts' +import { test as pageSidebarTest } from '../support/fixtures/pageSidebar.ts' +import { getToast } from '../support/helpers/toasts.ts' +import { webdavUrl } from '../support/helpers/urls.ts' + +// A page with the versions v1 (initial), v2 and v3 (current) +const test = mergeTests(createCollectivesTest, pageSidebarTest).extend<{ versionedPage: CollectivePage }>({ + versionedPage: async ({ collective, user, page }, use) => { + const collectivePage = await collective.createPage({ title: 'Versioned page', content: 'v1', user, page }) + for (const content of ['v2', 'v3']) { + // Changes within the same second don't create a new version + await page.waitForTimeout(1100) + await collectivePage.setContent({ content, user, page }) + } + await use(collectivePage) + }, +}) + +test.describe('Page versions', () => { + test.beforeEach(async ({ versionedPage, pageSidebar }) => { + await versionedPage.open() + await pageSidebar.openSidebarTab('Versions') + }) + + /** + * Click an action of the version at the index, the current version being first + * + * @param page - The Playwright page + * @param index - Index in the versions list + * @param action - Name of the action + */ + async function clickVersionAction(page: Page, index: number, action: string) { + await page.locator('.version-list .list-item') + .nth(index) + .getByRole('button', { name: /^Actions for versions/ }) + .click() + await page.getByRole('menuitem', { name: action }).click() + } + + test('lists and opens versions', async ({ page, pageSidebar }) => { + const versions = page.locator('.version-list .list-item') + await expect(versions).toHaveCount(3) + await expect(versions.first()).toContainText('Current version') + await expect(versions.last()).toContainText('Initial version') + + await versions.nth(1).click() + await expect(page.locator('.page-title-container .title-version')).toBeVisible() + await expect(page.locator('[data-cy-collectives="reader"]')).toContainText('v2') + + await pageSidebar.getVersionListItem('Current version').click() + await expect(page.locator('.page-title-container .title-version')).toHaveCount(0) + await expect(page.locator('[data-cy-collectives="reader"]')).toContainText('v3') + }) + + test('names a version', async ({ page }) => { + await clickVersionAction(page, 1, 'Name this version') + await page.locator('.version-label-modal').getByRole('textbox').fill('Reviewed') + await page.keyboard.press('Enter') + + await expect(page.locator('.version-list .list-item').nth(1)).toContainText('Reviewed') + }) + + test('compares a version with the current version', async ({ page }) => { + await clickVersionAction(page, 2, 'Compare with current version') + + // Second locator can be removed once we only support Nextcloud 36+ + const viewer = page.locator('.viewer__modal, #viewer[role="dialog"]') + await expect(viewer).toContainText('v1') + await expect(viewer).toContainText('v3') + }) + + test('compares two selected versions', async ({ page }) => { + await page.getByRole('button', { name: 'Compare versions…' }).click() + const dialog = page.getByRole('dialog', { name: 'Compare versions' }) + // Preselected are the previous (v2) and the current version, pick v1 instead of the latter + await dialog.getByRole('combobox', { name: 'Later version' }).click() + // The options are appended to the body + await page.getByRole('option').nth(2).click() + await dialog.getByRole('button', { name: 'Compare' }).click() + + await expect(dialog).toHaveCount(0) + // Second locator can be removed once we only support Nextcloud 36+ + const viewer = page.locator('.viewer__modal, #viewer[role="dialog"]') + await expect(viewer).toContainText('v1') + await expect(viewer).toContainText('v2') + await expect(viewer).not.toContainText('v3') + }) + + test('restores a version', async ({ versionedPage, user, page }) => { + await clickVersionAction(page, 2, 'Restore version') + await expect(getToast(page, 'success', 'Restored')).toBeVisible() + + // Check the file instead of reloading: reloading makes the Text client + // replay its cached document state, overwriting the restored content. + const { data } = versionedPage + const response = await page.request.get(webdavUrl(user.account.userId, data.collectivePath, data.filePath, data.fileName)) + expect(await response.text()).toContain('v1') + }) + + test('deletes a version', async ({ page }) => { + await clickVersionAction(page, 1, 'Delete version') + + await expect(page.locator('.version-list .list-item')).toHaveCount(2) + }) +}) From 42608be91a80dd8d0f2a2cd475302741d79aaf1b Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 19 Oct 2026 15:53:09 +0200 Subject: [PATCH 4/9] fix(viewer): pass decoded sources of page versions to the viewer Nodes of `@nextcloud/files` take decoded sources and encode them for requests. The versions store has encoded sources, so comparing versions of pages with special characters in the name requested a double encoded URL and failed to load the current version. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- src/tests/util/viewer.spec.ts | 14 ++++++++++++++ src/util/viewer.ts | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/tests/util/viewer.spec.ts b/src/tests/util/viewer.spec.ts index e9db743b48..534511cc9e 100644 --- a/src/tests/util/viewer.spec.ts +++ b/src/tests/util/viewer.spec.ts @@ -60,6 +60,20 @@ describe('compareVersions', () => { ) }) + it('passes decoded sources to the viewer library', async () => { + const compare = vi.fn() + vi.mocked(canCompare).mockReturnValue(true) + vi.mocked(getViewer).mockReturnValue({ compare } as never) + const source = 'https://cloud.example/remote.php/dav/files/alice/Collectives/Garden/My%20page%20%231.md' + + await compareVersions({ ...current, source }, version) + + expect(compare).toHaveBeenCalledWith( + expect.objectContaining({ source: 'https://cloud.example/remote.php/dav/files/alice/Collectives/Garden/My page #1.md' }), + expect.anything(), + ) + }) + it('uses the earlier version as base regardless of the order', async () => { const compare = vi.fn() vi.mocked(canCompare).mockReturnValue(true) diff --git a/src/util/viewer.ts b/src/util/viewer.ts index 3375f31349..27380f403f 100644 --- a/src/util/viewer.ts +++ b/src/util/viewer.ts @@ -52,7 +52,8 @@ interface VersionInfo { * A page version, or the current page, as the viewer compares it * * Old versions carry the page's own id and a `/versions/` root, which the - * Text viewer relies on to keep the editor read-only. + * Text viewer relies on to keep the editor read-only. The versions store has + * encoded sources, while nodes take decoded ones and encode them for requests. * * @param version - Entry from the versions store, or the current version */ @@ -60,7 +61,7 @@ export function versionNode(version: VersionInfo): File { const isVersion = version.filename?.startsWith('/versions/') return fileNode({ id: version.fileId, - source: version.source, + source: decodeSource(version.source), mime: version.mime, root: isVersion ? `/versions/${getCurrentUser()?.uid}` : getRootPath(), size: version.size, @@ -69,6 +70,16 @@ export function versionNode(version: VersionInfo): File { }) } +/** + * Decode the path of an encoded URL, segment by segment + * + * @param source - URL with an encoded path + */ +function decodeSource(source: string): string { + const { origin, pathname } = new URL(source) + return origin + pathname.split('/').map(decodeURIComponent).join('/') +} + // Needed for Nextcloud 35 and below. Can be removed once we only support Nextcloud 36+ interface LegacyViewer { availableHandlers: { mimes: string[] }[] From 5fba96e85fd5254dad7ca3919790114bf8f9b564 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 19 Oct 2026 23:37:31 +0200 Subject: [PATCH 5/9] fix(viewer): keep the sidebar tab when the viewer shows a file next to it The viewer asks for the sidebar whenever it shows a file while an app sidebar is visible, e.g. when comparing versions from the versions tab. Only switch to the attachments tab when the sidebar wasn't open yet. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- src/components/PageContainer.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/PageContainer.vue b/src/components/PageContainer.vue index 08c30ae18e..8cd6b0e1ad 100644 --- a/src/components/PageContainer.vue +++ b/src/components/PageContainer.vue @@ -177,7 +177,11 @@ export default { }, onViewerSidebarOpen() { - this.toggleSidebar({ open: true, tab: 'attachments' }) + // The viewer also asks for the sidebar when it shows a file next to the + // open sidebar, e.g. when comparing versions from the versions tab + if (!this.showingSidebar) { + this.toggleSidebar({ open: true, tab: 'attachments' }) + } }, }, } From 6f9ac3b82dc64e05cf82c4835b9bbd6b8d356aee Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 19 Oct 2026 23:42:08 +0200 Subject: [PATCH 6/9] fix(versions): close the viewer before comparing other versions The Text comparison in the viewer loads the two versions only once. Comparing other versions while a comparison is still open replaced the files in the open viewer, but Text kept waiting for them to load and the viewer showed its spinner forever. Closing the viewer first makes Text load the newly compared versions. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- playwright/e2e/page-versions.spec.ts | 13 +++++++++++++ src/tests/util/viewer.spec.ts | 3 ++- src/util/viewer.ts | 9 ++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/playwright/e2e/page-versions.spec.ts b/playwright/e2e/page-versions.spec.ts index 019c382554..eaa563c8bd 100644 --- a/playwright/e2e/page-versions.spec.ts +++ b/playwright/e2e/page-versions.spec.ts @@ -95,6 +95,19 @@ test.describe('Page versions', () => { await expect(viewer).not.toContainText('v3') }) + test('compares again while a comparison is open', async ({ page }) => { + await clickVersionAction(page, 1, 'Compare with current version') + // Second locator can be removed once we only support Nextcloud 36+ + const viewer = page.locator('.viewer__modal, #viewer[role="dialog"]') + await expect(viewer).toContainText('v2') + + // The page sidebar stays open next to the viewer + await clickVersionAction(page, 2, 'Compare with current version') + await expect(viewer).toContainText('v1') + await expect(viewer).toContainText('v3') + await expect(viewer).not.toContainText('v2') + }) + test('restores a version', async ({ versionedPage, user, page }) => { await clickVersionAction(page, 2, 'Restore version') await expect(getToast(page, 'success', 'Restored')).toBeVisible() diff --git a/src/tests/util/viewer.spec.ts b/src/tests/util/viewer.spec.ts index 534511cc9e..adc3dc17a7 100644 --- a/src/tests/util/viewer.spec.ts +++ b/src/tests/util/viewer.spec.ts @@ -88,9 +88,10 @@ describe('compareVersions', () => { }) it('falls back to the Viewer app', async () => { + const close = vi.fn() const compare = vi.fn() vi.mocked(canCompare).mockReturnValue(false) - vi.stubGlobal('window', { OCA: { Viewer: { registerHandler: vi.fn(), compare } } }) + vi.stubGlobal('window', { OCA: { Viewer: { registerHandler: vi.fn(), close, compare } } }) await compareVersions(current, version) diff --git a/src/util/viewer.ts b/src/util/viewer.ts index 27380f403f..fa46c2436e 100644 --- a/src/util/viewer.ts +++ b/src/util/viewer.ts @@ -85,6 +85,7 @@ interface LegacyViewer { availableHandlers: { mimes: string[] }[] open: (options: { path: string, list?: unknown[] }) => void compare: (fileInfo: object, compareFileInfo: object) => void + close: () => void } /** @@ -126,7 +127,13 @@ export async function compareVersions(first: VersionInfo, second: VersionInfo): await getViewer().compare(versionNode(later), versionNode(earlier)) return } - legacyViewer()?.compare({ ...later }, { ...earlier }) + const legacy = legacyViewer() + if (legacy) { + // The Viewer app keeps showing the first comparison. Re-open to workaround this. + legacy.close() + await new Promise((resolve) => setTimeout(resolve)) + legacy.compare({ ...later }, { ...earlier }) + } } type DavStat = Parameters[0] From 19463462e6a0f59a37f7b496e81acea4a993fc3f Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 20 Oct 2026 09:13:32 +0200 Subject: [PATCH 7/9] feat(versions): name the compared versions in the viewer The viewer titled a comparison with the file names: the page's Markdown file and the numeric id of the version. The compared versions carry the names the versions tab shows now: current version, the version's label, initial version or the date. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- .../PageSidebar/SidebarTabVersions.vue | 28 +++++++++++++++---- src/util/viewer.ts | 7 ++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/components/PageSidebar/SidebarTabVersions.vue b/src/components/PageSidebar/SidebarTabVersions.vue index 8c1d0b2fd4..04b7047c39 100644 --- a/src/components/PageSidebar/SidebarTabVersions.vue +++ b/src/components/PageSidebar/SidebarTabVersions.vue @@ -170,17 +170,30 @@ export default { .reduce((a, b) => Math.min(a, b)) }, + versionName() { + return (version) => { + if (this.isCurrent(version.mtime)) { + return t('collectives', 'Current version') + } + if (version.label) { + return version.label + } + return version.mtime === this.initialVersionMtime + ? t('collectives', 'Initial version') + // With seconds to tell apart versions of the same minute + : moment(version.mtime).format('LL LTS') + } + }, + compareOptions() { return this.sortedVersions.map((version) => { const isCurrent = this.isCurrent(version.mtime) - const name = isCurrent - ? t('collectives', 'Current version') - : version.label || (version.mtime === this.initialVersionMtime ? t('collectives', 'Initial version') : '') + const displayname = this.versionName(version) // With seconds to tell apart versions of the same minute const date = moment(version.mtime).format('LL LTS') return { - label: name ? `${name} · ${date}` : date, - version: isCurrent ? this.currentVersion : version, + label: displayname === date ? date : `${displayname} · ${date}`, + version: { ...(isCurrent ? this.currentVersion : version), displayname }, } }) }, @@ -282,7 +295,10 @@ export default { }, onCompareVersion(version) { - compareVersions(this.currentVersion, version) + compareVersions( + { ...this.currentVersion, displayname: this.versionName(this.currentVersion) }, + { ...version, displayname: this.versionName(version) }, + ) }, async onRestoreVersion(version) { diff --git a/src/util/viewer.ts b/src/util/viewer.ts index fa46c2436e..7df91033c8 100644 --- a/src/util/viewer.ts +++ b/src/util/viewer.ts @@ -16,6 +16,7 @@ interface FileNodeOptions { size?: number mtime?: Date etag?: string + displayname?: string } /** @@ -24,7 +25,7 @@ interface FileNodeOptions { * @param options - Properties of the file */ export function fileNode(options: FileNodeOptions): File { - const { id, source, mime, root, size, mtime, etag } = options + const { id, source, mime, root, size, mtime, etag, displayname } = options return new File({ id, source, @@ -35,6 +36,7 @@ export function fileNode(options: FileNodeOptions): File { size, mtime, attributes: { etag }, + displayname, }) } @@ -46,6 +48,8 @@ interface VersionInfo { size: number mtime: number etag: string + /** Name of the version in the viewer's title */ + displayname?: string } /** @@ -67,6 +71,7 @@ export function versionNode(version: VersionInfo): File { size: version.size, mtime: new Date(version.mtime), etag: version.etag, + displayname: version.displayname, }) } From 45491ce86a0eb5fb77ab405c04ffa610fcc6009c Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 22 Oct 2026 10:51:27 +0200 Subject: [PATCH 8/9] fix(versions): save the page before comparing the current version The editor autosave might have a delay of up to 10 seconds, so comparing the current version could miss the latest changes. The versions tab asks the editor to save first, through an event like the one the attachments tab uses to reach it. If saving fails, an error says so and the comparison shows the last saved version. Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-opus-5-5 --- playwright/e2e/page-versions.spec.ts | 16 +++++++++++++- src/components/Page/TextEditor.vue | 8 +++++++ .../PageSidebar/SidebarTabVersions.vue | 21 ++++++++++++++++--- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/playwright/e2e/page-versions.spec.ts b/playwright/e2e/page-versions.spec.ts index eaa563c8bd..bf7f6c6976 100644 --- a/playwright/e2e/page-versions.spec.ts +++ b/playwright/e2e/page-versions.spec.ts @@ -8,12 +8,13 @@ import type { CollectivePage } from '../support/fixtures/CollectivePage.ts' import { expect, mergeTests } from '@playwright/test' import { test as createCollectivesTest } from '../support/fixtures/create-collectives.ts' +import { test as editorTest } from '../support/fixtures/editor.ts' import { test as pageSidebarTest } from '../support/fixtures/pageSidebar.ts' import { getToast } from '../support/helpers/toasts.ts' import { webdavUrl } from '../support/helpers/urls.ts' // A page with the versions v1 (initial), v2 and v3 (current) -const test = mergeTests(createCollectivesTest, pageSidebarTest).extend<{ versionedPage: CollectivePage }>({ +const test = mergeTests(createCollectivesTest, editorTest, pageSidebarTest).extend<{ versionedPage: CollectivePage }>({ versionedPage: async ({ collective, user, page }, use) => { const collectivePage = await collective.createPage({ title: 'Versioned page', content: 'v1', user, page }) for (const content of ['v2', 'v3']) { @@ -78,6 +79,19 @@ test.describe('Page versions', () => { await expect(viewer).toContainText('v3') }) + test('compares unsaved changes with an older version', async ({ editor, page }) => { + await editor.switchMode(true) + await editor.getContent().getByText('v3').click() + await page.keyboard.press('End') + await page.keyboard.type(' unsaved') + + await clickVersionAction(page, 2, 'Compare with current version') + // Second locator can be removed once we only support Nextcloud 36+ + const viewer = page.locator('.viewer__modal, #viewer[role="dialog"]') + await expect(viewer).toContainText('v1') + await expect(viewer).toContainText('v3 unsaved') + }) + test('compares two selected versions', async ({ page }) => { await page.getByRole('button', { name: 'Compare versions…' }).click() const dialog = page.getByRole('dialog', { name: 'Compare versions' }) diff --git a/src/components/Page/TextEditor.vue b/src/components/Page/TextEditor.vue index 8b598b9d21..b4798d84b0 100644 --- a/src/components/Page/TextEditor.vue +++ b/src/components/Page/TextEditor.vue @@ -132,6 +132,7 @@ export default { } } }) + subscribe('collectives:editor:save', this.onSaveRequest) subscribe('collectives:attachment:insert', this.insertAttachment) subscribe('collectives:attachment:replaceFilename', this.replaceAttachmentFilename) subscribe('collectives:attachment:removeReferences', this.removeAttachmentReferences) @@ -141,6 +142,7 @@ export default { unsubscribe('collectives:attachment:removeReferences', this.removeAttachmentReferences) unsubscribe('collectives:attachment:replaceFilename', this.replaceAttachmentFilename) unsubscribe('collectives:attachment:insert', this.insertAttachment) + unsubscribe('collectives:editor:save', this.onSaveRequest) this.textEditWatcher() }, @@ -193,6 +195,12 @@ export default { this.editor?.focus() }, + onSaveRequest({ waitUntil }) { + if (this.isTextEdit && this.editor) { + waitUntil(this.saveEditor()) + } + }, + // called from the parent component as well saveEditor() { return this.editor.save() diff --git a/src/components/PageSidebar/SidebarTabVersions.vue b/src/components/PageSidebar/SidebarTabVersions.vue index 04b7047c39..6d8adb9269 100644 --- a/src/components/PageSidebar/SidebarTabVersions.vue +++ b/src/components/PageSidebar/SidebarTabVersions.vue @@ -72,11 +72,13 @@ + @compare="onCompare" />