test(files): Make scrolling tests independent from magic values

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/50582/head
Ferdinand Thiessen 2025-01-31 14:58:03 +07:00
parent d9996b92dc
commit 5530cdd3fd
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
4 changed files with 185 additions and 80 deletions

@ -48,3 +48,43 @@ export enum UnifiedSearchFilter {
export function getUnifiedSearchFilter(filter: UnifiedSearchFilter) {
return getUnifiedSearchModal().find(`[data-cy-unified-search-filters] [data-cy-unified-search-filter="${CSS.escape(filter)}"]`)
}
/**
* Assertion that an element is fully within the current viewport.
* @param $el The element
* @param expected If the element is expected to be fully in viewport or not fully
* @example
* ```js
* cy.get('#my-element')
* .should(beFullyInViewport)
* ```
*/
export function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) {
const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect()
const innerHeight = Cypress.$('body').innerHeight()!
const innerWidth = Cypress.$('body').innerWidth()!
const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth
console.debug(`fullyVisible: ${fullyVisible}, top: ${top >= 0}, left: ${left >= 0}, bottom: ${bottom <= innerHeight}, right: ${right <= innerWidth}`)
if (expected) {
// eslint-disable-next-line no-unused-expressions
expect(fullyVisible, 'Fully within viewport').to.be.true
} else {
// eslint-disable-next-line no-unused-expressions
expect(fullyVisible, 'Not fully within viewport').to.be.false
}
}
/**
* Opposite of `beFullyInViewport` - resolves when element is not or only partially in viewport.
* @param $el The element
* @example
* ```js
* cy.get('#my-element')
* .should(notBeFullyInViewport)
* ```
*/
export function notBeFullyInViewport($el: JQuery<HTMLElement>) {
return beFullyInViewport($el, false)
}

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from "@nextcloud/cypress"
import type { User } from '@nextcloud/cypress'
export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`)
@ -214,3 +214,45 @@ export const reloadCurrentFolder = () => {
cy.get('[data-cy-files-content-breadcrumbs]').findByRole('button', { description: 'Reload current directory' }).click()
cy.wait('@propfind')
}
/**
* Enable the grid mode for the files list.
* Will fail if already enabled!
*/
export function enableGridMode() {
cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode')
cy.findByRole('button', { name: 'Switch to grid view' })
.should('be.visible')
.click()
cy.wait('@setGridMode')
}
/**
* Calculate the needed viewport height to limit the visible rows of the file list.
* Requires a logged in user.
*
* @param rows The number of rows that should be displayed at the same time
*/
export function calculateViewportHeight(rows: number): Cypress.Chainable<number> {
cy.visit('/apps/files')
return cy.get('[data-cy-files-list]')
.should('be.visible')
.then((filesList) => {
const windowHeight = Cypress.$('body').outerHeight()!
// Size of other page elements
const outerHeight = Math.ceil(windowHeight - filesList.outerHeight()!)
// Size of before and filters
const beforeHeight = Math.ceil(Cypress.$('.files-list__before').outerHeight()!)
const filterHeight = Math.ceil(Cypress.$('.files-list__filters').outerHeight()!)
// Size of the table header
const tableHeaderHeight = Math.ceil(Cypress.$('[data-cy-files-list-thead]').outerHeight()!)
// table row height
const rowHeight = Math.ceil(Cypress.$('[data-cy-files-list-tbody] tr').outerHeight()!)
// sum it up
const viewportHeight = outerHeight + beforeHeight + filterHeight + tableHeaderHeight + rows * rowHeight
cy.log(`Calculated viewport height: ${viewportHeight} (${outerHeight} + ${beforeHeight} + ${filterHeight} + ${tableHeaderHeight} + ${rows} * ${rowHeight})`)
return cy.wrap(viewportHeight)
})
}

@ -4,7 +4,7 @@
*/
import type { User } from '@nextcloud/cypress'
import { getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils'
import { calculateViewportHeight, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils'
describe('files: Rename nodes', { testIsolation: true }, () => {
let user: User
@ -12,7 +12,12 @@ describe('files: Rename nodes', { testIsolation: true }, () => {
beforeEach(() => cy.createRandomUser().then(($user) => {
user = $user
// remove welcome file
cy.rm(user, '/welcome.txt')
// create a file called "file.txt"
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
// login and visit files app
cy.login(user)
cy.visit('/apps/files')
}))
@ -116,34 +121,6 @@ describe('files: Rename nodes', { testIsolation: true }, () => {
.should('not.exist')
})
/**
* This is a regression test of: https://github.com/nextcloud/server/issues/47438
* The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list
* due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling).
*/
it('correctly resets renaming state', () => {
for (let i = 1; i <= 20; i++) {
cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`)
}
cy.viewport(1200, 500) // 500px is smaller then 20 * 50 which is the place that the files take up
cy.login(user)
cy.visit('/apps/files')
getRowForFile('file.txt').should('be.visible')
// Z so it is shown last
renameFile('file.txt', 'zzz.txt')
// not visible any longer
getRowForFile('zzz.txt').should('not.be.visible')
// scroll file list to bottom
cy.get('[data-cy-files-list]').scrollTo('bottom')
cy.screenshot()
// The file is no longer in rename state
getRowForFile('zzz.txt')
.should('be.visible')
.findByRole('textbox', { name: 'Filename' })
.should('not.exist')
})
it('cancel renaming on esc press', () => {
// All are visible by default
getRowForFile('file.txt').should('be.visible')
@ -182,4 +159,38 @@ describe('files: Rename nodes', { testIsolation: true }, () => {
.find('input[type="text"]')
.should('not.exist')
})
/**
* This is a regression test of: https://github.com/nextcloud/server/issues/47438
* The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list
* due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling).
*/
it('correctly resets renaming state', () => {
// Create 19 additional files
for (let i = 1; i <= 19; i++) {
cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`)
}
// Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered
cy.viewport(768, 500)
cy.login(user)
calculateViewportHeight(4)
.then((height) => cy.viewport(768, height))
cy.visit('/apps/files')
getRowForFile('file.txt').should('be.visible')
// Z so it is shown last
renameFile('file.txt', 'zzz.txt')
// not visible any longer
getRowForFile('zzz.txt').should('not.exist')
// scroll file list to bottom
cy.get('[data-cy-files-list]').scrollTo('bottom')
cy.screenshot()
// The file is no longer in rename state
getRowForFile('zzz.txt')
.should('be.visible')
.findByRole('textbox', { name: 'Filename' })
.should('not.exist')
})
})

@ -4,11 +4,13 @@
*/
import type { User } from '@nextcloud/cypress'
import { getRowForFile } from './FilesUtils'
import { calculateViewportHeight, enableGridMode, getRowForFile } from './FilesUtils.ts'
import { beFullyInViewport, notBeFullyInViewport } from '../core-utils.ts'
describe('files: Scrolling to selected file in file list', { testIsolation: true }, () => {
const fileIds = new Map<number, string>()
let user: User
let viewportHeight: number
before(() => {
cy.createRandomUser().then(($user) => {
@ -19,12 +21,17 @@ describe('files: Scrolling to selected file in file list', { testIsolation: true
cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`)
.then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString()))
}
cy.login(user)
cy.viewport(1200, 800)
// Calculate height to ensure that those 10 elements can not be rendered in one list (only 6 will fit the screen)
calculateViewportHeight(6)
.then((height) => { viewportHeight = height })
})
})
beforeEach(() => {
// Adjust height to ensure that those 10 elements can not be rendered in one list
cy.viewport(1200, 6 * 55 /* rows */ + 55 /* table header */ + 50 /* navigation header */ + 50 /* breadcrumbs */ + 46 /* file filters */)
cy.viewport(1200, viewportHeight)
cy.login(user)
})
@ -64,34 +71,36 @@ describe('files: Scrolling to selected file in file list', { testIsolation: true
.and(beOverlappedByTableHeader)
getRowForFile(`${i + 5}.txt`)
.should('exist')
.and('be.visible')
.and(notBeFullyInViewport)
})
}
// this will have half of the footer visible
it(`correctly scrolls to row 6`, () => {
// this will have half of the footer visible and half of the previous element
it('correctly scrolls to row 6', () => {
cy.visit(`/apps/files/files/${fileIds.get(6)}`)
// See file is visible
getRowForFile(`6.txt`)
getRowForFile('6.txt')
.should('be.visible')
.and(notBeOverlappedByTableHeader)
// we expect also element 7,8,9,10 visible
getRowForFile(`10.txt`)
getRowForFile('10.txt')
.should('be.visible')
// but not row 5
getRowForFile(`5.txt`)
getRowForFile('5.txt')
.should('exist')
.and(beOverlappedByTableHeader)
// see footer is only shown partly
cy.get('tfoot')
.should('exist')
.and(notBeFullyInViewport)
.contains('10 files')
.should('be.visible')
})
// Same kind of tests for partially visible top and bottom
// For the last "page" of entries we can not scroll further
// so we show all of the last 4 entries
for (let i = 7; i <= 10; i++) {
it(`correctly scrolls to row ${i}`, () => {
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
@ -101,15 +110,15 @@ describe('files: Scrolling to selected file in file list', { testIsolation: true
.should('be.visible')
.and(notBeOverlappedByTableHeader)
// there are only max. 3 rows left so also row 6+ should be visible
getRowForFile(`6.txt`)
// there are only max. 4 rows left so also row 6+ should be visible
getRowForFile('6.txt')
.should('be.visible')
getRowForFile(`10.txt`)
getRowForFile('10.txt')
.should('be.visible')
// Also the footer is visible
cy.get('tfoot')
.contains('10 files')
.should('be.visible')
.should(beFullyInViewport)
})
}
})
@ -117,8 +126,13 @@ describe('files: Scrolling to selected file in file list', { testIsolation: true
describe('files: Scrolling to selected file in file list (GRID MODE)', { testIsolation: true }, () => {
const fileIds = new Map<number, string>()
let user: User
let viewportHeight: number
before(() => {
cy.wrap(Cypress.automation('remote:debugger:protocol', {
command: 'Network.clearBrowserCache',
}))
cy.createRandomUser().then(($user) => {
user = $user
@ -127,21 +141,22 @@ describe('files: Scrolling to selected file in file list (GRID MODE)', { testIso
cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`)
.then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString()))
}
// Set grid mode
cy.login(user)
cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode')
cy.visit('/apps/files')
cy.findByRole('button', { name: 'Switch to grid view' })
.should('be.visible')
.click()
cy.wait('@setGridMode')
enableGridMode()
// 768px width will limit the columns to 3
cy.viewport(768, 800)
// Calculate height to ensure that those 12 elements can not be rendered in one list (only 3 will fit the screen)
calculateViewportHeight(3)
.then((height) => { viewportHeight = height })
})
})
beforeEach(() => {
// Adjust height to ensure that those 12 files can not be rendered in one list
// 768px width will limit the columns to 3
cy.viewport(768, 3 * 246 /* rows */ + 55 /* table header */ + 50 /* navigation header */ + 50 /* breadcrumbs */ + 46 /* file filters */)
cy.viewport(768, viewportHeight)
cy.login(user)
})
@ -155,13 +170,13 @@ describe('files: Scrolling to selected file in file list (GRID MODE)', { testIso
getRowForFile(`${j}.txt`)
.should('be.visible')
// we expect also the second row to be visible
getRowForFile(`${j+3}.txt`)
getRowForFile(`${j + 3}.txt`)
.should('be.visible')
// Because there is no half row on top we also see the third row
getRowForFile(`${j+6}.txt`)
getRowForFile(`${j + 6}.txt`)
.should('be.visible')
// But not the forth row
getRowForFile(`${j+9}.txt`)
getRowForFile(`${j + 9}.txt`)
.should('exist')
.and(notBeFullyInViewport)
}
@ -215,8 +230,9 @@ describe('files: Scrolling to selected file in file list (GRID MODE)', { testIso
// see footer is only shown partly
cy.get('tfoot')
.should('exist')
.and(notBeFullyInViewport)
.should(notBeFullyInViewport)
.contains('span', '12 files')
.should('be.visible')
})
}
@ -237,44 +253,40 @@ describe('files: Scrolling to selected file in file list (GRID MODE)', { testIso
// see footer is shown
cy.get('tfoot')
.should('be.visible')
.contains('.files-list__row-name', '12 files')
.should(beFullyInViewport)
})
}
})
/// Some helpers
function notBeOverlappedByTableHeader($el: JQuery<HTMLElement>) {
return beOverlappedByTableHeader($el, false)
}
/**
* Assert that an element is overlapped by the table header
* @param $el The element
* @param expected if it should be overlapped or NOT
*/
function beOverlappedByTableHeader($el: JQuery<HTMLElement>, expected = true) {
const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect()
const elementRect = $el.get(0)!.getBoundingClientRect()
const overlap = !(headerRect.right < elementRect.left ||
headerRect.left > elementRect.right ||
headerRect.bottom < elementRect.top ||
headerRect.top > elementRect.bottom)
const overlap = !(headerRect.right < elementRect.left
|| headerRect.left > elementRect.right
|| headerRect.bottom < elementRect.top
|| headerRect.top > elementRect.bottom)
if (expected) {
// eslint-disable-next-line no-unused-expressions
expect(overlap, 'Overlapped by table header').to.be.true
} else {
// eslint-disable-next-line no-unused-expressions
expect(overlap, 'Not overlapped by table header').to.be.false
}
}
function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) {
const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect()
const { innerHeight, innerWidth } = window
const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth
if (expected) {
expect(fullyVisible, 'Fully within viewport').to.be.true
} else {
expect(fullyVisible, 'Not fully within viewport').to.be.false
}
}
function notBeFullyInViewport($el: JQuery<HTMLElement>) {
return beFullyInViewport($el, false)
/**
* Assert that an element is not overlapped by the table header
* @param $el The element
*/
function notBeOverlappedByTableHeader($el: JQuery<HTMLElement>) {
return beOverlappedByTableHeader($el, false)
}