diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts index e1ba1c5eed8..701a9852dba 100644 --- a/cypress/e2e/collection-edit.cy.ts +++ b/cypress/e2e/collection-edit.cy.ts @@ -122,6 +122,9 @@ describe('Edit Collection > Delete page', () => { // tag must be loaded cy.get('ds-delete-collection').should('be.visible'); + // Wait for inner content to render before running axe + cy.get('ds-delete-collection h1#header', { timeout: 30000 }).should('be.visible'); + // Analyze for accessibility issues testA11y('ds-delete-collection'); }); diff --git a/cypress/e2e/community-edit.cy.ts b/cypress/e2e/community-edit.cy.ts index 77e260feec0..51f52a79d9a 100644 --- a/cypress/e2e/community-edit.cy.ts +++ b/cypress/e2e/community-edit.cy.ts @@ -80,6 +80,9 @@ describe('Edit Community > Delete page', () => { // tag must be loaded cy.get('ds-delete-community').should('be.visible'); + // Wait for inner content to render before running axe + cy.get('ds-delete-community h1#header', { timeout: 30000 }).should('be.visible'); + // Analyze for accessibility issues testA11y('ds-delete-community'); }); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts index ad5d8ea0930..6668387e22a 100644 --- a/cypress/e2e/item-edit.cy.ts +++ b/cypress/e2e/item-edit.cy.ts @@ -46,6 +46,9 @@ describe('Edit Item > Status tab', () => { // tag must be loaded cy.get('ds-item-status').should('be.visible'); + // Wait for the actual status content to render before running axe + cy.get('ds-item-status .status-label', { timeout: 30000 }).should('be.visible'); + // Analyze for accessibility issues testA11y('ds-item-status'); }); diff --git a/cypress/e2e/item-page.cy.ts b/cypress/e2e/item-page.cy.ts index b79b6ac31d1..658efb645d5 100644 --- a/cypress/e2e/item-page.cy.ts +++ b/cypress/e2e/item-page.cy.ts @@ -26,6 +26,12 @@ describe('Item Page', () => { // tag must be loaded cy.get('ds-full-item-page').should('be.visible'); + // Wait for the inner content (item-page) to actually render — the host + // element gets its size from padding/header even before the item details + // resolve, so visibility alone isn't enough for axe to find any nodes. + cy.get('ds-full-item-page .item-page', { timeout: 30000 }).should('exist'); + cy.get('ds-full-item-page ds-item-page-title-field', { timeout: 30000 }).should('be.visible'); + // Analyze for accessibility issues testA11y('ds-full-item-page'); }); diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 9a9ea1121ba..c74b5eb3aba 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -40,5 +40,29 @@ 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) => { + // 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(elements as any, options, terminalLog); + }); + return; + } cy.checkA11y(context, options, terminalLog); }; 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)"> {{'item.edit.head' | translate}} @if ((page.enabled | async) !== true) { } diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html index 53ef93bc54c..29e75014875 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html @@ -3,7 +3,8 @@
@if (linkType !== linkTypes.None) { + [routerLink]="[itemPageRoute]" class="dont-break-out" tabindex="-1" + [attr.aria-label]="dsoNameService.getName(dso)"> 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 }), ); }