From 522e1126f4b2b04bbcc65e5bb6ac2e8c49b01b46 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 13:46:30 +0200 Subject: [PATCH 1/7] fix(cypress): stabilize login flow and hide Klaro consent banner in e2e tests' --- cypress/support/commands.ts | 27 +++++++++++++++++++++++---- cypress/support/e2e.ts | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index a8e831da1da..fb7d192662e 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -100,13 +100,32 @@ function loginViaForm( ): void { cy.wait(500); - // Fill in credentials - cy.get('[data-test="email"]').should('be.visible').type(email); - cy.get('[data-test="password"]').type(password); + // Intercept the login POST so we can deterministically wait for it to complete, + // instead of racing the default 4s cy.get() timeout against a slow CI auth chain + // (POST /authn/login -> /authn/status -> NgRx state update -> router navigation + // away from /login -> home page render). On slower CI runners this routinely + // takes longer than 4s and the next `cy.get('#sidebar-collapse-toggle')` fails + // while the page is still on /login showing the "Loading..." spinner. + cy.intercept('POST', '**/api/authn/login').as('loginRequest'); + + // Fill in credentials. + // NOTE: on the standalone /login page the form is rendered twice in the DOM + // (once in the page body, once as the hidden navbar login dropdown). We must + // therefore scope the selectors to the visible form, otherwise + // `should('be.visible')` against the multi-element subject fails because the + // navbar copy lives inside a `display: none` dropdown. + cy.get('[data-test="email"]:visible').first().should('be.visible').type(email); + cy.get('[data-test="password"]:visible').first().type(password); // Submit the form - cy.get('[data-test="login-button"]').click(); + cy.get('[data-test="login-button"]:visible').first().click(); + + // Wait for the login POST to return successfully before letting the test continue. + cy.wait('@loginRequest').its('response.statusCode').should('eq', 200); + // Wait for the post-login redirect away from /login so the home page (and its + // sidebar) has a chance to render before the test assertions run. + cy.location('pathname', { timeout: 20000 }).should('not.match', /\/login$/); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 10e785e1845..bc0487c44e3 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -57,12 +57,34 @@ before(() => { beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. - cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); + cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true%2C%22accessibility%22:true}'); // Remove any CSRF cookies saved from prior tests cy.clearCookie(DSPACE_XSRF_COOKIE); }); +// Hide the Klaro cookie-consent banner in every test window. Even with a pre-set +// klaro-anonymous cookie, Klaro may still render the notice (e.g. when its +// internal consent version changes after a config update), and that notice +// overlaps interactive elements such as the admin sidebar toggle. Injecting a +// `display: none` rule for the `.klaro` container at every page load keeps the +// banner from intercepting clicks during e2e tests. +Cypress.on('window:before:load', (win) => { + const injectKlaroHider = () => { + if (!win.document.getElementById('cypress-hide-klaro')) { + const style = win.document.createElement('style'); + style.id = 'cypress-hide-klaro'; + style.textContent = '.klaro { display: none !important; }'; + (win.document.head || win.document.documentElement).appendChild(style); + } + }; + if (win.document && win.document.head) { + injectKlaroHider(); + } else { + win.addEventListener('DOMContentLoaded', injectKlaroHider, { once: true }); + } +}); + // NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL // from the Angular UI's config.json. See 'before()' above. const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; From d19fb57956b943d25e0e6410430fa4cc444825a9 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 15:27:54 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=EF=BB=BFfix(cypress):=20use=20programmatic?= =?UTF-8?q?=20REST=20login=20to=20bypass=20hanging=20UI=20form=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI evidence (run 26398927955) shows every login-protected spec failing with: 'cy.wait() timed out waiting 30000ms for the 1st response to the route: loginRequest. No response ever occurred.' The browser-side POST /api/authn/login dispatched from the Angular login form does not receive a response within Cypress' default 30s wait on the ubuntu-latest runner, while anonymous specs (23/55) pass. Switching to cy.request() drives the login from the Cypress (Node) side directly against the backend, bypassing CORS/XSRF/SSR timing problems, and writes the resulting auth token into the same UI cookie Angular reads on bootstrap. A subsequent cy.reload() rehydrates Angular as an authenticated user and preserves the original returnUrl for specs that visit a restricted page first (e.g. /mydspace, /submit). Also addresses Copilot review feedback on the prior attempt: removes the hard cy.wait(500) and encodes the klaro-anonymous cookie via JSON.stringify + encodeURIComponent. --- cypress/support/commands.ts | 76 +++++++++++++++++++++---------------- cypress/support/e2e.ts | 14 ++++++- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index fb7d192662e..c00f14652a6 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -89,43 +89,55 @@ function login(email: string, password: string): void { Cypress.Commands.add('login', login); /** - * Login user via displayed login form + * Login the given user via the REST API and inject the resulting auth token + * into the UI session cookie. Despite the historical "ViaForm" name this is + * now a fully programmatic login — clicking through the UI form is unreliable + * on the CI runners (the browser-side POST /api/authn/login routinely fails + * to receive a response within Cypress' default 30s wait, which previously + * caused every login-protected spec to fail with "No response ever + * occurred"). Going through cy.request() instead bypasses the SSR layer and + * any browser-side CORS/XSRF timing problems, then a single cy.visit('/') + * forces Angular to rehydrate as an authenticated user. + * * @param email email to login as * @param password password to login as */ -// Cypress custom command for form-based login with intercept and redirect assertion -function loginViaForm( - email: string, - password: string -): void { - cy.wait(500); - - // Intercept the login POST so we can deterministically wait for it to complete, - // instead of racing the default 4s cy.get() timeout against a slow CI auth chain - // (POST /authn/login -> /authn/status -> NgRx state update -> router navigation - // away from /login -> home page render). On slower CI runners this routinely - // takes longer than 4s and the next `cy.get('#sidebar-collapse-toggle')` fails - // while the page is still on /login showing the "Loading..." spinner. - cy.intercept('POST', '**/api/authn/login').as('loginRequest'); - - // Fill in credentials. - // NOTE: on the standalone /login page the form is rendered twice in the DOM - // (once in the page body, once as the hidden navbar login dropdown). We must - // therefore scope the selectors to the visible form, otherwise - // `should('be.visible')` against the multi-element subject fails because the - // navbar copy lives inside a `display: none` dropdown. - cy.get('[data-test="email"]:visible').first().should('be.visible').type(email); - cy.get('[data-test="password"]:visible').first().type(password); +function loginViaForm(email: string, password: string): void { + // Each invocation needs a fresh CSRF cookie/token pair, since prior tests + // (or this test's own beforeEach) explicitly clear the XSRF cookie. + cy.createCSRFCookie().then((csrfToken: string) => { + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { [XSRF_REQUEST_HEADER]: csrfToken }, + // form-urlencoded body, matching what the Angular login form sends + form: true, + body: { user: email, password: password }, + // Be generous: the very first login on a freshly-started DSpace + // backend in CI can take well over 30s while Hibernate warms up. + timeout: 120000, + }).then((resp) => { + expect(resp.status, 'login POST status').to.eq(200); + expect(resp.headers, 'login response headers').to.have.property('authorization'); - // Submit the form - cy.get('[data-test="login-button"]:visible').first().click(); - - // Wait for the login POST to return successfully before letting the test continue. - cy.wait('@loginRequest').its('response.statusCode').should('eq', 200); + // Persist the auth token into the UI cookie that Angular reads on + // bootstrap so the subsequent navigation is already authenticated. + const authHeader = resp.headers.authorization as string; + const authInfo: AuthTokenInfo = new AuthTokenInfo(authHeader); + cy.setCookie(TOKENITEM, JSON.stringify(authInfo)); + }); + }); + }); - // Wait for the post-login redirect away from /login so the home page (and its - // sidebar) has a chance to render before the test assertions run. - cy.location('pathname', { timeout: 20000 }).should('not.match', /\/login$/); + // Force Angular to re-bootstrap with the new auth cookie. cy.reload() + // preserves the current URL, so specs that visit a restricted page first + // (e.g. /mydspace -> redirected to /login?returnUrl=/mydspace) still end up + // back at the original destination after login. For specs that visit + // /login directly, the login page sees the authenticated user on bootstrap + // and redirects to /home. + cy.reload(); + cy.location('pathname', { timeout: 30000 }).should('not.match', /\/login$/); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index bc0487c44e3..9a63d47eda5 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -53,11 +53,23 @@ before(() => { }); }); +// Pre-agreed Klaro consent payload. Keep this list in sync with the services +// declared in src/app/shared/cookies/klaro-configuration.ts — otherwise Klaro +// detects a configuration change and re-shows the consent banner during tests. +const KLARO_CONSENT_PAYLOAD = encodeURIComponent(JSON.stringify({ + authentication: true, + preferences: true, + acknowledgement: true, + 'google-analytics': true, + 'google-recaptcha': true, + accessibility: true, +})); + // Runs once before the first test in each "block" beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. - cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true%2C%22accessibility%22:true}'); + cy.setCookie('klaro-anonymous', KLARO_CONSENT_PAYLOAD); // Remove any CSRF cookies saved from prior tests cy.clearCookie(DSPACE_XSRF_COOKIE); From 62e52c4c7c020816294978029d06e5c5ddac4cf0 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 8 Jun 2026 14:26:28 +0200 Subject: [PATCH 3/7] Empty commit From c2c9e7be38b5870628c5920c69aba4bdc881d6f5 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 8 Jun 2026 16:05:00 +0200 Subject: [PATCH 4/7] fix(cypress): revert loginViaForm to form-based login With the backend auth-chain fix (PasswordAuthentication first) the admin login no longer hangs, so the programmatic cy.request() login introduced earlier is unnecessary. That programmatic login also left specs which start on /login stranded there (the injected auth cookie did not trigger the app's away-from-login redirect), failing ~17 login-gated specs with "expected '/login' not to match". Reverting to the proven form-based login fixes those. Co-Authored-By: Claude Opus 4.8 --- cypress/support/commands.ts | 57 ++++++++++--------------------------- 1 file changed, 15 insertions(+), 42 deletions(-) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c00f14652a6..555275d5114 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -89,55 +89,28 @@ function login(email: string, password: string): void { Cypress.Commands.add('login', login); /** - * Login the given user via the REST API and inject the resulting auth token - * into the UI session cookie. Despite the historical "ViaForm" name this is - * now a fully programmatic login — clicking through the UI form is unreliable - * on the CI runners (the browser-side POST /api/authn/login routinely fails - * to receive a response within Cypress' default 30s wait, which previously - * caused every login-protected spec to fail with "No response ever - * occurred"). Going through cy.request() instead bypasses the SSR layer and - * any browser-side CORS/XSRF timing problems, then a single cy.visit('/') - * forces Angular to rehydrate as an authenticated user. + * Login the given user via the displayed login form. + * + * NOTE: this previously used a programmatic cy.request() login as a workaround + * for admin logins hanging on CI. The real cause was the backend image trying + * an unreachable LDAP server first in its authentication chain (fixed in the + * DSpace backend by putting PasswordAuthentication first). With that resolved, + * the straightforward form-based login is reliable again and avoids the + * cookie-injection edge case where a spec that starts on /login was not + * redirected away after a programmatic login. * * @param email email to login as * @param password password to login as */ function loginViaForm(email: string, password: string): void { - // Each invocation needs a fresh CSRF cookie/token pair, since prior tests - // (or this test's own beforeEach) explicitly clear the XSRF cookie. - cy.createCSRFCookie().then((csrfToken: string) => { - cy.task('getRestBaseURL').then((baseRestUrl: string) => { - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/authn/login', - headers: { [XSRF_REQUEST_HEADER]: csrfToken }, - // form-urlencoded body, matching what the Angular login form sends - form: true, - body: { user: email, password: password }, - // Be generous: the very first login on a freshly-started DSpace - // backend in CI can take well over 30s while Hibernate warms up. - timeout: 120000, - }).then((resp) => { - expect(resp.status, 'login POST status').to.eq(200); - expect(resp.headers, 'login response headers').to.have.property('authorization'); + cy.wait(500); - // Persist the auth token into the UI cookie that Angular reads on - // bootstrap so the subsequent navigation is already authenticated. - const authHeader = resp.headers.authorization as string; - const authInfo: AuthTokenInfo = new AuthTokenInfo(authHeader); - cy.setCookie(TOKENITEM, JSON.stringify(authInfo)); - }); - }); - }); + // Fill in credentials + cy.get('[data-test="email"]').should('be.visible').type(email); + cy.get('[data-test="password"]').type(password); - // Force Angular to re-bootstrap with the new auth cookie. cy.reload() - // preserves the current URL, so specs that visit a restricted page first - // (e.g. /mydspace -> redirected to /login?returnUrl=/mydspace) still end up - // back at the original destination after login. For specs that visit - // /login directly, the login page sees the authenticated user on bootstrap - // and redirects to /home. - cy.reload(); - cy.location('pathname', { timeout: 30000 }).should('not.match', /\/login$/); + // Submit the form + cy.get('[data-test="login-button"]').click(); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); From d3f1b152815821c7698f28e62416da8699800387 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 8 Jun 2026 16:59:06 +0200 Subject: [PATCH 5/7] fix(cypress): stabilize submission & admin-workflow specs on slow CI These VSB/CLARIN pages render slower than Cypress' 4s default, which caused the last two failures after the login fix: - submission: the CLARIN submission form loads several controlled vocabularies before the metadata fields appear, so input#dc_title was not yet in the DOM. Wait for ds-submission-edit and give dc_title a generous timeout. After a failed deposit the metadata accordion section can collapse (ngb-accordion drops its body), so re-expand it before asserting the title field is invalid. - admin-workflow: the supervision-configured search can take a while to return on a freshly started backend; allow extra time for the first list-object to render. Co-Authored-By: Claude Opus 4.8 --- cypress/e2e/admin-workflow-page.cy.ts | 5 +++-- cypress/e2e/submission.cy.ts | 22 ++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/admin-workflow-page.cy.ts b/cypress/e2e/admin-workflow-page.cy.ts index 4feff0bee0f..579562ab9bf 100644 --- a/cypress/e2e/admin-workflow-page.cy.ts +++ b/cypress/e2e/admin-workflow-page.cy.ts @@ -8,8 +8,9 @@ describe('Admin Workflow Page', () => { it('should pass accessibility tests', () => { // Page must first be visible cy.get('ds-admin-workflow-page').should('be.visible'); - // At least one search result should be displayed - cy.get('[data-test="list-object"]').should('be.visible'); + // At least one search result should be displayed. The supervision-configured search on a + // freshly-started CI backend can take a while to return, so allow extra time. + cy.get('[data-test="list-object"]', { timeout: 30000 }).should('be.visible'); // Click each filter toggle to open *every* filter // (As we want to scan filter section for accessibility issues as well) cy.get('[data-test="filter-toggle"]').click({ multiple: true }); diff --git a/cypress/e2e/submission.cy.ts b/cypress/e2e/submission.cy.ts index 474d7c6cf2b..cfaa6806ae6 100644 --- a/cypress/e2e/submission.cy.ts +++ b/cypress/e2e/submission.cy.ts @@ -59,6 +59,11 @@ describe('New Submission page', () => { // This page is restricted, so we will be shown the login form. Fill it out & submit. cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + // The CLARIN submission form is heavy (loads several controlled vocabularies), + // so wait until it has fully rendered before interacting with it. + cy.get('ds-submission-edit').should('be.visible'); + cy.get('input#dc_title', { timeout: 30000 }).should('exist'); + // Attempt an immediate deposit without filling out any fields cy.get('button#deposit').click(); @@ -70,8 +75,17 @@ describe('New Submission page', () => { // (as it has required fields) cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible'); + // After a failed deposit the metadata accordion section may be collapsed, which (with + // ngb-accordion) removes its fields from the DOM. Ensure it is expanded before asserting + // on the title field. + cy.get('body').then(($body) => { + if ($body.find('input#dc_title').length === 0) { + cy.get('div#traditionalpageone-header').click(); + } + }); + // Title field should have class "is-invalid" applied, as it's required - cy.get('input#dc_title').should('have.class', 'is-invalid'); + cy.get('input#dc_title', { timeout: 15000 }).should('have.class', 'is-invalid'); // Date Year field should also have "is-valid" class cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid'); @@ -120,8 +134,12 @@ describe('New Submission page', () => { // This page is restricted, so we will be shown the login form. Fill it out & submit. cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + // The CLARIN submission form is heavy (loads several controlled vocabularies), + // so wait until it has fully rendered before interacting with it. + cy.get('ds-submission-edit').should('be.visible'); + // Fill out all required fields (Title, Date) - cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); + cy.get('input#dc_title', { timeout: 30000 }).type('DSpace logo uploaded via e2e tests'); cy.get('input#dc_date_issued_year').type('2022'); // Confirm the required license by checking checkbox From 2b6da23006c3b4f1e4eede638d3c19396f90ba14 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 8 Jun 2026 17:36:28 +0200 Subject: [PATCH 6/7] fix(cypress): match VSB dc.title