From b2b017e83154c30aaded9f85fee0cdd67acc6b29 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 11:58:21 +0200 Subject: [PATCH 1/7] Fix a11y link-name on thumbnail anchors in search-result list elements --- .../journal-issue-search-result-list-element.component.html | 3 ++- .../journal-volume-search-result-list-element.component.html | 3 ++- .../journal/journal-search-result-list-element.component.html | 3 ++- .../project/project-search-result-list-element.component.html | 3 ++- .../item/item-search-result-list-element.component.html | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html index baca36538b8..f0c4aa06674 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html @@ -4,7 +4,8 @@ @if (linkType !== linkTypes.None) { + [routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1" + [attr.aria-label]="dsoNameService.getName(dso)"> diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html index 115c1af4494..3b5564030f3 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html @@ -4,7 +4,8 @@ @if (linkType !== linkTypes.None) { + [routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1" + [attr.aria-label]="dsoNameService.getName(dso)"> diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html index 535e516b582..db41d77dc78 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html @@ -3,7 +3,8 @@
@if (linkType !== linkTypes.None) { + [routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1" + [attr.aria-label]="dsoNameService.getName(dso)"> diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html index bee8ec05bcf..ab25dd4f42c 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html @@ -4,7 +4,8 @@ @if (linkType !== linkTypes.None) { + [routerLink]="[itemPageRoute]" class="dont-break-out" tabindex="-1" + [attr.aria-label]="dsoNameService.getName(dso)"> @if (linkType !== linkTypes.None) { + [routerLink]="[itemPageRoute]" class="dont-break-out" tabindex="-1" + [attr.aria-label]="dsoNameService.getName(dso)"> From 3b852c452ef7eabb57ca33ee9643d8f02a469c46 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 13:55:07 +0200 Subject: [PATCH 2/7] fix(metadata-link): cache resolver config permanently to stabilize /full item-page render MetadataLinkService is a singleton (providedIn: root) that fetches 5 backend configuration properties in parallel via combineLatest. The template on the full item page subscribes to it via async pipes inside an @for loop over metadata entries; subscriber count fluctuates as rows render, and with shareReplay({ refCount: true }) the inner subscription was torn down and re-created repeatedly, causing the 5 HTTP calls to repeat and the metadata table to render slowly or inconsistently on Cypress CI runners. - Switch shareReplay to refCount: false so resolver values are cached for the lifetime of the service. - Eagerly subscribe in the constructor so the fetches start as soon as the service is created (during the first FullItemPageComponent instantiation), warming the replay buffer before template subscriptions run. --- src/app/shared/utils/metadata-link.service.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/shared/utils/metadata-link.service.ts b/src/app/shared/utils/metadata-link.service.ts index 4955b6412dc..62dd6aedcce 100644 --- a/src/app/shared/utils/metadata-link.service.ts +++ b/src/app/shared/utils/metadata-link.service.ts @@ -55,6 +55,15 @@ export class MetadataLinkService { constructor(private configService: ConfigurationDataService) { this.resolvers$ = this.loadResolvers(); + // Eagerly trigger the resolver config fetch as soon as the service is + // instantiated (singleton, providedIn: 'root'). Without this, the + // resolver$ observable is only subscribed to lazily by template + // `async` pipes — and combined with `refCount: true` on shareReplay, + // every time the last subscriber unsubscribes (e.g. during view churn + // on /full item page) the 5 HTTP calls would be repeated. An eager + // subscription keeps the replay buffer warm for the lifetime of the + // service. + this.resolvers$.subscribe(); } /** @@ -88,7 +97,14 @@ export class MetadataLinkService { openPolicyFinder: fetch('openPolicyFinder'), jcr: fetch('jcr'), }).pipe( - shareReplay({ bufferSize: 1, refCount: true }), + // refCount: false — once the resolver config is loaded, cache it for + // the lifetime of the service (singleton). With refCount: true the + // inner subscription would be torn down whenever subscriber count + // dropped to zero, causing the 5 HTTP calls to repeat on the next + // subscription. The values are immutable per backend deployment, so + // a permanent cache is safe and avoids redundant network traffic on + // /full item pages with many metadata rows. + shareReplay({ bufferSize: 1, refCount: false }), ); } From 066fb0b0736f45fd8b487aadcb65f20247ae77e6 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 14:47:45 +0200 Subject: [PATCH 3/7] Revert "fix(metadata-link): cache resolver config permanently to stabilize /full item-page render" This reverts commit 3b852c452ef7eabb57ca33ee9643d8f02a469c46. --- src/app/shared/utils/metadata-link.service.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/app/shared/utils/metadata-link.service.ts b/src/app/shared/utils/metadata-link.service.ts index 62dd6aedcce..4955b6412dc 100644 --- a/src/app/shared/utils/metadata-link.service.ts +++ b/src/app/shared/utils/metadata-link.service.ts @@ -55,15 +55,6 @@ export class MetadataLinkService { constructor(private configService: ConfigurationDataService) { this.resolvers$ = this.loadResolvers(); - // Eagerly trigger the resolver config fetch as soon as the service is - // instantiated (singleton, providedIn: 'root'). Without this, the - // resolver$ observable is only subscribed to lazily by template - // `async` pipes — and combined with `refCount: true` on shareReplay, - // every time the last subscriber unsubscribes (e.g. during view churn - // on /full item page) the 5 HTTP calls would be repeated. An eager - // subscription keeps the replay buffer warm for the lifetime of the - // service. - this.resolvers$.subscribe(); } /** @@ -97,14 +88,7 @@ export class MetadataLinkService { openPolicyFinder: fetch('openPolicyFinder'), jcr: fetch('jcr'), }).pipe( - // refCount: false — once the resolver config is loaded, cache it for - // the lifetime of the service (singleton). With refCount: true the - // inner subscription would be torn down whenever subscriber count - // dropped to zero, causing the 5 HTTP calls to repeat on the next - // subscription. The values are immutable per backend deployment, so - // a permanent cache is safe and avoids redundant network traffic on - // /full item pages with many metadata rows. - shareReplay({ bufferSize: 1, refCount: false }), + shareReplay({ bufferSize: 1, refCount: true }), ); } From 419332485568144722ed2d5201a58f06ad28826c Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 16:59:16 +0200 Subject: [PATCH 4/7] fix(a11y): satisfy aria-required-children on edit-item tablist and wait for inner content before axe checks - edit-item-page tablist had disabled } From a7ce6bc779e79254a79f506716aca6faaa198d5c Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 26 May 2026 09:14:37 +0200 Subject: [PATCH 5/7] fix(a11y,metadata-link): wait for host content before axe + cache resolver config permanently Previous push reduced failures from 5 to 3 tests; remaining 3 all fail with 'No elements found for include in page Context': /full (despite 30s wait for .item-page + ds-item-page-title-field that PASSED), item-edit Bitstreams tab, community-edit Assign Roles tab. 1) testA11y(): when include is a CSS string, wait up to 30s for any child to exist on the host before running axe. Eliminates host-without-content races uniformly across every axe-checked spec. 2) MetadataLinkService: shareReplay refCount true -> false, plus eager subscribe in constructor. Service is providedIn root with combineLatest of 5 backend configuration HTTP calls. On /full item page, async pipes inside @for over metadata rows churn subscribers; with refCount true each churn tears down the inner subscription and re-fetches all 5 properties, causing the template to repeatedly re-evaluate and produce empty intermediate render states (visible as blank main content in the CI failure screenshot). Caching for the service lifetime stabilises the render. --- cypress/support/utils.ts | 8 ++++++++ src/app/shared/utils/metadata-link.service.ts | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 9a9ea1121ba..5947f84779d 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -32,6 +32,14 @@ function terminalLog(violations: Result[]) { // while also ensuring any violations are logged to the terminal (see terminalLog above) // This method MUST be called after cy.visit(), as cy.injectAxe() must be called after page load export const testA11y = (context?: any, options?: Options) => { + // When the include context is a CSS selector string targeting a single host element + // (e.g. 'ds-full-item-page'), the host can satisfy :visible due to layout/padding + // before Angular has finished projecting its real content. In that state, axe-core + // walks the empty include and fails with "No elements found for include in page Context". + // Wait until the host has actually rendered child content before running axe. + if (typeof context === 'string') { + cy.get(`${context} *`, { timeout: 30000 }).should('exist'); + } cy.injectAxe(); cy.configureAxe({ rules: [ diff --git a/src/app/shared/utils/metadata-link.service.ts b/src/app/shared/utils/metadata-link.service.ts index 4955b6412dc..00e45c8c833 100644 --- a/src/app/shared/utils/metadata-link.service.ts +++ b/src/app/shared/utils/metadata-link.service.ts @@ -55,6 +55,12 @@ export class MetadataLinkService { constructor(private configService: ConfigurationDataService) { this.resolvers$ = this.loadResolvers(); + // Eagerly trigger the resolver config fetch as soon as the service is + // instantiated (singleton, providedIn: 'root'). Without this, resolvers$ + // is only subscribed to lazily by template `async` pipes. Combined with + // refCount: false on shareReplay this warms the replay buffer for the + // lifetime of the service so that the 5 HTTP calls happen only once. + this.resolvers$.subscribe(); } /** @@ -88,7 +94,14 @@ export class MetadataLinkService { openPolicyFinder: fetch('openPolicyFinder'), jcr: fetch('jcr'), }).pipe( - shareReplay({ bufferSize: 1, refCount: true }), + // refCount: false — once the resolver config is loaded, cache it for + // the lifetime of the service (singleton). With refCount: true the + // inner subscription would be torn down whenever subscriber count + // dropped to zero (e.g. during view churn in the @for over metadata + // rows on /full item page), causing the 5 HTTP calls to repeat and + // the metadata table to re-render. The values are immutable per + // backend deployment, so a permanent cache is safe. + shareReplay({ bufferSize: 1, refCount: false }), ); } From 4c6b48ff306f881c6d3e67cf8f4e9f2bfce09ea1 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 26 May 2026 09:53:26 +0200 Subject: [PATCH 6/7] fix(a11y): pass resolved Element to axe instead of selector string to avoid 'No elements found' race After previous push, CI improved 3->3 failures but the remaining 3 (community Delete, item-edit Curate, /full) all still fail with 'No elements found for include in page Context'. Root cause: cypress-axe forwards the string selector context to axe.run, which re-resolves it via document.querySelectorAll at axe.run time. Between Cypress commands (injectAxe reads the axe-core source file, configureAxe evals window.axe.configure) several ms elapse; Angular can re-render and remove the host element from the document for one microtask, making the selector resolve to zero elements. Fix: in testA11y, after waiting for the host with rendered descendants, resolve the selector ourselves and pass the live DOM Element reference (not the selector string) to cy.checkA11y. axe-core uses the Element directly and skips selector resolution, eliminating the race. --- cypress/support/utils.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 5947f84779d..02debcc3b2c 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -32,14 +32,6 @@ function terminalLog(violations: Result[]) { // while also ensuring any violations are logged to the terminal (see terminalLog above) // This method MUST be called after cy.visit(), as cy.injectAxe() must be called after page load export const testA11y = (context?: any, options?: Options) => { - // When the include context is a CSS selector string targeting a single host element - // (e.g. 'ds-full-item-page'), the host can satisfy :visible due to layout/padding - // before Angular has finished projecting its real content. In that state, axe-core - // walks the empty include and fails with "No elements found for include in page Context". - // Wait until the host has actually rendered child content before running axe. - if (typeof context === 'string') { - cy.get(`${context} *`, { timeout: 30000 }).should('exist'); - } cy.injectAxe(); cy.configureAxe({ rules: [ @@ -48,5 +40,25 @@ export const testA11y = (context?: any, options?: Options) => { { id: 'color-contrast', enabled: false }, ], }); + // When the include context is a CSS selector string, axe-core re-resolves + // that selector at axe.run time via document.querySelectorAll. Between + // Cypress commands (injectAxe + configureAxe take a few ms each) Angular + // may re-render and the host element can briefly disappear from the + // document, causing axe to throw + // "No elements found for include in page Context". + // + // Fix: wait for the host to exist with rendered content, then resolve the + // selector to a live DOM Element here and pass that Element reference (not + // the selector string) to cy.checkA11y. axe-core uses the Element directly + // and does not re-query the document by selector, eliminating the race. + if (typeof context === 'string') { + cy.get(context, { timeout: 30000 }).should('exist'); + cy.get(`${context} *`, { timeout: 30000 }).should('exist'); + cy.get(context).then(($el) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cy.checkA11y($el[0] as any, options, terminalLog); + }); + return; + } cy.checkA11y(context, options, terminalLog); }; From 6e6024e4f2e702591f4fd5029dc5e6b9d9ee756a Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Wed, 27 May 2026 13:53:19 +0200 Subject: [PATCH 7/7] test(a11y): pass all matched elements to checkA11y, not just first Addresses Copilot review on PR #1294: when selector matches multiple elements, scanning only \[0] reduced coverage. Use \.toArray() so axe sees all matches like the original string-context did. --- cypress/support/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 02debcc3b2c..c74b5eb3aba 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -55,8 +55,12 @@ export const testA11y = (context?: any, options?: Options) => { cy.get(context, { timeout: 30000 }).should('exist'); cy.get(`${context} *`, { timeout: 30000 }).should('exist'); cy.get(context).then(($el) => { + // Pass ALL matched elements (not just the first) so that selectors which + // resolve to multiple nodes preserve the original string-context coverage. + // axe-core accepts an Array of Elements as Context. + const elements = $el.toArray(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - cy.checkA11y($el[0] as any, options, terminalLog); + cy.checkA11y(elements as any, options, terminalLog); }); return; }