diff --git a/.github/workflows/cypress-e2e.yml b/.github/workflows/cypress-e2e.yml index ea1fac101..bb67ff585 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 aedac9d7e..af17199da 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 935f1ca03..6e1ebbb5b 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 c681d22bc..904dac28a 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 a31169dd7..000000000 --- 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 deleted file mode 100644 index d9b86a8b5..000000000 --- a/cypress/e2e/page-versions.spec.js +++ /dev/null @@ -1,1640 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * 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() { - 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) - - // 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) - .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) - .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) - .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) - }) - - 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') - }) - - openVersionsSidebar() - }) - - it('Lists versions', function() { - cy.getReadOnlyEditor() - .should('contain', CURRENT_PHRASE) - - 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('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') - .click() - - cy.get('.page-title-container') - .find('.title-version') - .should('be.visible') - cy.getReadOnlyEditor() - .should('contain', INITIAL_PHRASE) - - 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', CURRENT_PHRASE) - }) - - 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('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) - - 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') - }) - - 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') - // 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() { - 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').its('response.statusCode').should('be.oneOf', [201, 204]) - cy.contains('[role="status"]', 'Restored').should('be.visible') - - cy.request('/csrftoken').then(({ body }) => { - cy.request({ - url: `${Cypress.expose('baseUrl')}/remote.php/webdav/.Collectives/${encodeURIComponent(COLLECTIVE_NAME)}/${encodeURIComponent(PAGE_NAME)}.md`, - headers: { requesttoken: body.token }, - }).its('body') - .should('contain', INITIAL_PHRASE) - .and('not.contain', CURRENT_PHRASE) - }) - }) - - 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() - - cy.intercept('DELETE', '**/dav/versions/**').as('deleteVersion') - cy.clickMenuButton('Delete version') - cy.wait('@deleteVersion') - - cy.get('.app-sidebar-tabs__content .version-list .list-item') - .should('have.length', $versions.length - 1) - }) - }) -}) - -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 501722015..68c322d96 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 3786cea69..d5208193f 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/page-versions.spec.ts b/playwright/e2e/page-versions.spec.ts new file mode 100644 index 000000000..cb62f79f1 --- /dev/null +++ b/playwright/e2e/page-versions.spec.ts @@ -0,0 +1,179 @@ +/** + * 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 editorTest } from '../support/fixtures/editor.ts' +import { test as pageSidebarTest } from '../support/fixtures/pageSidebar.ts' +import { hasServerVersion } from '../support/helpers/server.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, 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']) { + // Changes within the same second don't create a new version + await page.waitForTimeout(1100) + await collectivePage.setContent({ content, user, page }) + } + await use(collectivePage) + }, +}) + +/** + * 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.describe('Page versions', () => { + test.beforeEach(async ({ versionedPage, pageSidebar }) => { + await versionedPage.open() + await pageSidebar.openSidebarTab('Versions') + }) + + 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 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' }) + // 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('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() + + // 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) + }) +}) + +// A page with an image in its versions v1 and v2 (current) +const attachmentsTest = test.extend<{ pageWithImage: CollectivePage }>({ + pageWithImage: async ({ collective, user, page }, use) => { + const collectivePage = await collective.createPage({ title: 'Page with image', user, page }) + const src = await collectivePage.uploadImage({ filename: 'test.png', user, page }) + for (const content of [`v1\n\n![test](${src})\n`, `v2\n\n![test](${src})\n`]) { + // Changes within the same second don't create a new version + await page.waitForTimeout(1100) + await collectivePage.setContent({ content, user, page }) + } + await use(collectivePage) + }, +}) + +attachmentsTest.describe('Page versions with attachments', () => { + attachmentsTest('shows the images of both versions compared', async ({ pageWithImage, pageSidebar, page }) => { + // Before Nextcloud 35, the Viewer app shows both versions as they are, without their images + attachmentsTest.skip(hasServerVersion(33, 34), 'Text compares versions only since Nextcloud 35') + await pageWithImage.open() + await pageSidebar.openSidebarTab('Versions') + 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('v1') + await expect(viewer).toContainText('v2') + // One image on each side. Text shows an icon instead of an image it + // cannot load. The Viewer app on Nextcloud 35 and older keeps a hidden + // pane with the page as well. + const images = viewer.locator('img.image__main').filter({ visible: true }) + await expect(images).toHaveCount(2) + for (const image of await images.all()) { + await expect.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth)).toBeGreaterThan(0) + } + }) +}) diff --git a/playwright/e2e/version-comparison.spec.ts b/playwright/e2e/version-comparison.spec.ts deleted file mode 100644 index 2e51b68ed..000000000 --- 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 eff7b406d..000000000 --- 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 8d621645a..8c8997712 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 15baa0ce2..38ffd88e4 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 d46c11db6..b4798d84b 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() @@ -135,16 +132,17 @@ 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) }, beforeUnmount() { - this.unregisterCurrentSnapshotPreparer?.() 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() }, @@ -152,7 +150,6 @@ export default { t, ...mapActions(useRootStore, ['load', 'done']), - ...mapActions(useVersionsStore, ['getVersions', 'registerCurrentSnapshotPreparer']), ...mapActions(usePagesStore, ['setTextEdit', 'setTextPreview', 'touchPage']), ...mapActions(useCirclesStore, ['getCircleMembers']), @@ -198,18 +195,17 @@ 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() }, - 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/PageContainer.vue b/src/components/PageContainer.vue index 08c30ae18..8cd6b0e1a 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' }) + } }, }, } diff --git a/src/components/PageSidebar.vue b/src/components/PageSidebar.vue index 968ce2258..1e2301a12 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 49987ce71..8c47b9a78 100644 --- a/src/components/PageSidebar/SidebarTabVersions.vue +++ b/src/components/PageSidebar/SidebarTabVersions.vue @@ -25,12 +25,12 @@
+ @click="showCompareDialog = true"> {{ t('collectives', 'Compare versions…') }} @@ -46,7 +46,7 @@ :canEdit="currentCollectiveCanEdit" @click="onOpenVersion(version)" @startLabelUpdate="onStartLabelUpdate(version)" - @compare="onCompareVersion(version, $event)" + @compare="onCompareVersion(version)" @restore="onRestoreVersion(version)" @delete="onDeleteVersion(version)" /> @@ -69,17 +69,18 @@ :versionLabel="editedVersion.label" @labelUpdate="onLabelUpdate" /> - +
+ + diff --git a/src/components/PageSidebar/VersionComparisonDialog.vue b/src/components/PageSidebar/VersionComparisonDialog.vue deleted file mode 100644 index 19b947afc..000000000 --- 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 1d3dbfa65..993682a8a 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 83c8283ca..99c70d65d 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 fe8b7e6d4..000000000 --- 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 efc4bf185..000000000 --- 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 1f85c0bae..000000000 --- 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 6443a0b97..000000000 --- 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 5812aa27c..000000000 --- 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 bc7f3e19b..000000000 --- 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 000000000..b4aecc4a6 --- /dev/null +++ b/src/tests/util/viewer.spec.ts @@ -0,0 +1,118 @@ +/** + * 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, { id: 7, filePath: '/Collectives/Garden/Page.md' }) + + expect(compare).toHaveBeenCalledWith( + expect.objectContaining({ source: current.source }), + expect.objectContaining({ source: version.source, root: '/versions/alice' }), + ) + }) + + 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, { id: 7, filePath: '/Collectives/Garden/Page.md' }) + + 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) + vi.mocked(getViewer).mockReturnValue({ compare } as never) + + await compareVersions(version, current, { id: 7, filePath: '/Collectives/Garden/Page.md' }) + + expect(compare).toHaveBeenCalledWith( + expect.objectContaining({ source: current.source }), + expect.objectContaining({ source: version.source }), + ) + }) + + 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(), close, compare } } }) + + await compareVersions(version, current, { id: 7, filePath: '/Collectives/Garden/Page.md' }) + + expect(getViewer).not.toHaveBeenCalled() + expect(compare).toHaveBeenCalledWith(current, version) + }) + + it('passes the page to the Viewer app when Text compares the versions', async () => { + const close = vi.fn() + const compare = vi.fn() + vi.mocked(canCompare).mockReturnValue(false) + vi.stubGlobal('window', { + OCA: { + Text: { createMarkdownContentComparison: vi.fn() }, + Viewer: { registerHandler: vi.fn(), close, compare }, + }, + }) + + await compareVersions(current, version, { id: 7, filePath: '/Collectives/Garden/Page.md' }) + + // Text resolves attachments with the id and path of the later file + expect(compare).toHaveBeenCalledWith({ ...current, fileid: 7, filename: '/Collectives/Garden/Page.md' }, version) + }) +}) diff --git a/src/util/versionComparison.js b/src/util/versionComparison.js deleted file mode 100644 index bddb1b1b2..000000000 --- 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 d5c4bc8ba..000000000 --- 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 173658f5b..080128ff1 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,13 +48,16 @@ interface VersionInfo { size: number mtime: number etag: string + /** Name of the version in the viewer's title */ + displayname?: string } /** * 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,20 +65,32 @@ 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, mtime: new Date(version.mtime), etag: version.etag, + displayname: version.displayname, }) } +/** + * 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[] }[] open: (options: { path: string, list?: unknown[] }) => void compare: (fileInfo: object, compareFileInfo: object) => void + close: () => void } /** @@ -99,29 +116,40 @@ 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 + * + * 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 + * @param page - The page, for the Viewer app to resolve attachments + * @param page.id - Id of the page + * @param page.filePath - Path of the page's file, relative to the user's files root */ -export async function compareVersions(later: VersionInfo, earlier: VersionInfo): Promise { +export async function compareVersions(first: VersionInfo, second: VersionInfo, page: { id: number, filePath: string }): Promise { + const [earlier, later] = first.mtime <= second.mtime ? [first, second] : [second, first] if (canCompare(versionNode(later))) { 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)) + // Text compares Markdown versions in the Viewer app since Nextcloud 35, + // and resolves attachments with the id and path of the later file. Before, + // the Viewer app shows the files as they are, and a file with an id would + // open as the page in an editor. + // This can be refactored once we only support Nextcloud 35 and above. + const fileInfo = typeof window.OCA?.Text?.createMarkdownContentComparison === 'function' + ? { ...later, fileid: page.id, filename: page.filePath } + : { ...later } + legacy.compare(fileInfo, { ...earlier }) + } } type DavStat = Parameters[0]