Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,15 @@
}
},
"peerDependencies": {
"@playwright/test": ">=1.45"
"@playwright/test": ">=1.45",
"@axe-core/playwright": ">=4.10"
},
"peerDependenciesMeta": {
"@playwright/test": {
"optional": true
},
"@axe-core/playwright": {
"optional": true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ App.listColumns = {
resize(width + (event.key === 'ArrowRight' ? step : -step));
});
th.appendChild(grip);
// aria-valuenow is required on a focusable separator from the start, not only once it has
// been focused (#7645); focus and every resize keep it current.
announce();
},

// Is the element's text wider than its content box? scrollWidth alone rounds a sub-pixel overflow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@
<svg role="presentation" x-h-lucide data-lucide="flag" class="text-primary shrink-0"></svg>
<div class="text-sm">
<span class="font-semibold text-foreground" x-text="$store.processTasks.processName($store.processTasks.getTasks(form)[0])"></span>
<span class="text-muted-foreground" x-text="' — ' + T('$projectName:${tprefix}.messages.workflowStep', 'your step')"></span>
<!-- Foreground, not muted: on the primary-tinted strip muted text falls under 4.5:1 (#7645). -->
<span class="text-foreground" x-text="' — ' + T('$projectName:${tprefix}.messages.workflowStep', 'your step')"></span>
</div>
<div class="flex-1"></div>
<template x-for="task in $store.processTasks.getTasks(form)" :key="task.id">
Expand Down Expand Up @@ -683,7 +684,8 @@
<nav x-h-step-indicator="activeStep()" data-orientation="vertical">
<template x-for="(step, i) in statusSteps()" :key="step.value">
<div x-h-step-indicator-item="i + 1">
<button x-h-step-indicator-trigger data-non-interactive="true" type="button">
<!-- Harmonia marks the active step visually only; aria-current announces it (#7645). -->
<button x-h-step-indicator-trigger data-non-interactive="true" type="button" :aria-current="i + 1 === activeStep() ? 'step' : null">
<span x-h-step-indicator-marker x-text="i + 1"></span>
<span x-h-step-indicator-content>
<span x-h-step-indicator-title x-text="step.text"></span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ document.addEventListener('alpine:init', () => {
this.refreshIcons();
},

// The header's aria-sort: the sort state a screen reader announces with the column (#7645).
ariaSort(col) {
if (this.sortColumn !== col) return 'none';
return this.sortDirection === 'asc' ? 'ascending' : 'descending';
},
isSortedAsc(col) { return this.sortColumn === col && this.sortDirection === 'asc'; },
isSortedDesc(col) { return this.sortColumn === col && this.sortDirection === 'desc'; },
isUnsorted(col) { return this.sortColumn !== col; },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,14 @@
<tr x-h-table-row>
#foreach($property in $listProperties)
#if(!$property.dataAutoIncrement && $property.widgetIsMajor)
<th x-h-table-head scope="col" data-hoverable="true" data-activable="true"#if($property.isFloatType) class="text-right"#end#visibleGate($property) @click="cycleSort('${property.name}')">
<div class="hbox items-center justify-between">
<th x-h-table-head scope="col" data-hoverable="true" data-activable="true"#if($property.isFloatType) class="text-right"#end#visibleGate($property) :aria-sort="ariaSort('${property.name}')">
<!-- A real button sorts, so the header is reachable and operable from the keyboard (#7645). -->
<button type="button" class="hbox w-full cursor-pointer items-center justify-between" @click="cycleSort('${property.name}')">
<span x-text="T('$projectName:${tprefix}.t.${property.dataName}', '${property.name}')"></span>
<svg x-h-lucide role="presentation" data-lucide="arrow-up" class="size-4" x-show="isSortedAsc('${property.name}')"></svg>
<svg x-h-lucide role="presentation" data-lucide="arrow-down" class="size-4" x-show="isSortedDesc('${property.name}')"></svg>
<svg x-h-lucide role="presentation" data-lucide="arrow-up-down" class="size-4" x-show="isUnsorted('${property.name}')"></svg>
</div>
</button>
</th>
#end
#end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@
<nav x-h-step-indicator="([#foreach($s in $metadata.steps){ label: '$s.label.replace($SQ, $ESCSQ)' }#if($foreach.hasNext), #end#end]).findIndex(s => s.label === model.${metadata.statusVar}) + 1" data-orientation="horizontal" class="sm:col-span-2 mb-4">
<template x-for="(step, i) in [#foreach($s in $metadata.steps){ label: '$s.label.replace($SQ, $ESCSQ)', title: #if($s.translation)T('${projectName}:${tprefix}.t.${s.translation.replace($SQ, $ESCSQ)}', '$s.label.replace($SQ, $ESCSQ)')#{else}'$s.label.replace($SQ, $ESCSQ)'#end, description: #if($s.description)'$s.description.replace($SQ, $ESCSQ)'#{else}''#end }#if($foreach.hasNext), #end#end]" :key="step.label">
<div x-h-step-indicator-item="i + 1">
<button x-h-step-indicator-trigger data-non-interactive="true" type="button">
<!-- Harmonia marks the active step visually only; aria-current announces it (#7645). -->
<button x-h-step-indicator-trigger data-non-interactive="true" type="button" :aria-current="step.label === model.${metadata.statusVar} ? 'step' : null">
<span x-h-step-indicator-marker x-text="i + 1"></span>
<span x-h-step-indicator-content>
<span x-h-step-indicator-title x-text="step.title"></span>
Expand Down
11 changes: 10 additions & 1 deletion npm/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ runTest(fileURLToPath(new URL('../<project>/<name>.test', import.meta.url)));
```
BASE_URL=http://localhost:8080 npm test # defaults: admin/admin, installed Chrome
APPTEST_SHELL=1 npm test # also assert the shared-shell menu item
APPTEST_A11Y=strict npm test # fail on serious/critical accessibility violations
```

Env: `BASE_URL` (default `http://localhost:8080`), `APPTEST_USERNAME`/`APPTEST_PASSWORD`
(default `admin`/`admin`), `APPTEST_SHELL` (opt-in shared-shell flow).
(default `admin`/`admin`), `APPTEST_SHELL` (opt-in shared-shell flow), `APPTEST_A11Y` (`report`
default, `strict`, `off` - the accessibility flow).

## Flows per entity

Expand All @@ -69,6 +71,13 @@ Env: `BASE_URL` (default `http://localhost:8080`), `APPTEST_USERNAME`/`APPTEST_P
calendar on the Personal shell) is deliberately not asserted yet — it tracks the personal-template
parity fixes.
- **shell** (opt-in) — the shared application shell's nav item opens the module SPA in its iframe.
- **a11y** — axe-core scans the list page and the create form against WCAG 2.1 A/AA and attaches
each page's result (`axe-<Entity>-<page>.json`) to the test report. `APPTEST_A11Y=report`
(default) only reports - a module's first release; `strict` also fails on a `serious` or
`critical` violation; `off` skips it. Needs `@axe-core/playwright` in the harness
`devDependencies` (the flow skips itself without it). Contrast against Harmonia's own
`--primary` is not counted in strict mode: the default palette misses 4.5:1 there, which the
component library fixes; every other contrast failure is.

Test records carry an `APPTEST-` prefix and are removed in teardown; seed data is never mutated.
A field the app owns rather than the user — a `number:` series, a calculated or `readOnly:` value —
Expand Down
8 changes: 7 additions & 1 deletion npm/test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
"directory": "npm/test"
},
"peerDependencies": {
"@playwright/test": ">=1.45"
"@playwright/test": ">=1.45",
"@axe-core/playwright": ">=4.10"
},
"peerDependenciesMeta": {
"@axe-core/playwright": {
"optional": true
}
}
}
86 changes: 86 additions & 0 deletions npm/test/src/flows/a11y.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test } from '../fixtures.js';
import { CALENDAR_OR_SLOTS, listCrumb } from './list.js';

// The WCAG 2.1 level AA conformance rules, and the level A ones it includes (dirigible #7645).
const WCAG_21_AA = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
const ENFORCED_IMPACTS = new Set(['serious', 'critical']);

// report (default): attach every page's axe result to the test report and pass - the first
// release of a module. strict: also fail on a serious or critical violation. off: no scan.
const MODE = process.env.APPTEST_A11Y ?? 'report';

// Harmonia's default palette fails contrast against its own --primary (white on it, and it as link
// text in dark mode); that is fixed in the component library, so strict mode does not fail an app
// for it. Any other contrast failure still counts.
const PRIMARY_COLOR = () => {
const probe = document.createElement('div');
probe.style.color = 'var(--primary)';
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
const context = document.createElement('canvas').getContext('2d');
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
};

function sameColor(measured, expected) {
if (typeof measured !== 'string' || measured.length !== 7 || !expected) return false;
for (let channel = 1; channel < 7; channel += 2) {
const a = parseInt(measured.slice(channel, channel + 2), 16);
const b = parseInt(expected.slice(channel, channel + 2), 16);
if (Math.abs(a - b) > 2) return false;
}
return true;
}

function onPrimary(node, primary) {
return (node.any ?? []).some((check) => sameColor(check.data?.fgColor, primary) || sameColor(check.data?.bgColor, primary));
}

async function axeBuilder() {
try {
return (await import('@axe-core/playwright')).default;
} catch {
return null;
}
}

// The list page and the create form of every entity with a page of its own, scanned by axe-core.
export function a11yFlow(manifest, entity) {
if (MODE === 'off' || !entity.route) return;
test(`${entity.name}: list and form pages pass axe-core (WCAG 2.1 AA)`, async ({ page }, testInfo) => {
const AxeBuilder = await axeBuilder();
test.skip(!AxeBuilder, 'add @axe-core/playwright to the harness devDependencies to run the accessibility flow');

const calendar = entity.layout === 'calendar' || entity.layout === 'slots';
const pages = [['list', entity.route, () => (calendar ? page.locator(CALENDAR_OR_SLOTS).first() : listCrumb(page, entity))]];
if (!calendar && !entity.hierarchy) {
pages.push(['form', entity.route + '/create', () => page.locator('input, select, textarea').filter({ visible: true }).first()]);
}

const findings = [];
for (const [name, route, ready] of pages) {
await page.goto(manifest.standaloneShell + route);
await expect(ready()).toBeVisible();
const results = await new AxeBuilder({ page }).withTags(WCAG_21_AA).analyze();
await testInfo.attach(`axe-${entity.name}-${name}.json`, {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
const primary = await page.evaluate(PRIMARY_COLOR);
for (const violation of results.violations) {
if (!ENFORCED_IMPACTS.has(violation.impact)) continue;
const nodes = violation.nodes.filter((node) => !(violation.id === 'color-contrast' && onPrimary(node, primary)));
if (nodes.length) {
const targets = nodes.slice(0, 5).map((node) => node.target.join(' ')).join(', ');
findings.push(`${name}: [${violation.impact}] ${violation.id} - ${violation.help} (${nodes.length} elements: ${targets})`);
}
}
}
if (MODE === 'strict') {
expect(findings, 'serious or critical WCAG 2.1 AA violations:\n' + findings.join('\n')).toEqual([]);
}
});
}
2 changes: 2 additions & 0 deletions npm/test/src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import { test } from './fixtures.js';
import { a11yFlow } from './flows/a11y.js';
import { crudFlow } from './flows/crud.js';
import { listFlow } from './flows/list.js';
import { multilingualFlow } from './flows/multilingual.js';
Expand All @@ -20,6 +21,7 @@
myFlow(manifest, entity, opts);
multilingualFlow(manifest, entity, opts);
shellFlow(manifest, entity, opts);
a11yFlow(manifest, entity, opts);
});
}
}
Expand Down
14 changes: 14 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
<!-- Tests -->
<testcontainers.version>2.0.5</testcontainers.version>
<selenide.version>7.18.2</selenide.version>
<axe-core-selenium.version>4.13.0</axe-core-selenium.version>
<rest-assured.version>6.0.1</rest-assured.version>

<antlr4-maven-plugin.version>4.13.2</antlr4-maven-plugin.version>
Expand Down Expand Up @@ -1023,6 +1024,19 @@
<version>4.46.0</version>
<scope>test</scope>
</dependency>
<!-- axe-core accessibility scans on the browser the Selenide ITs drive (#7645); Selenide brings
the Selenium it runs on, so the binding's own (older) selenium-java stays out -->
<dependency>
<groupId>com.deque.html.axe-core</groupId>
<artifactId>selenium</artifactId>
<version>${axe-core-selenium.version}</version>
<exclusions>
<exclusion>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
Expand Down
5 changes: 5 additions & 0 deletions tests/tests-integrations/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
<artifactId>selenide</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.deque.html.axe-core</groupId>
<artifactId>selenium</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.dirigible.integration.tests.api;

import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

/**
* Accessibility rules of the Harmonia templates that the axe-core scan of a generated application
* ({@code AccessibilityHarmoniaIT}) cannot reach, because no intent application renders the markup
* - the perspective list view exists only for hand-authored models (#7645).
*/
class HarmoniaAccessibilityMarkupIT {

private static final List<String> TEMPLATE_PATTERNS =
List.of("classpath*:META-INF/dirigible/template-application-ui-harmonia-java/**/*.template",
"classpath*:META-INF/dirigible/template-form-builder-harmonia/**/*.template");

/** The opening tag of a table header cell. */
private static final Pattern TABLE_HEAD = Pattern.compile("<th\\b[^>]*>");

/** The opening tag of a step indicator trigger. */
private static final Pattern STEP_TRIGGER = Pattern.compile("<[a-z]+\\b[^>]*\\bx-h-step-indicator-trigger\\b[^>]*>");

@Test
void aSortableHeaderSortsThroughAButton() throws IOException {
List<String> problems = scan(TABLE_HEAD, tag -> tag.contains("@click"));
assertTrue(problems.isEmpty(), "a header cell that sorts on click is neither focusable nor operable from the keyboard - "
+ "put a <button type=\"button\"> inside it and bind :aria-sort on the cell:\n" + String.join("\n", problems));
}

@Test
void theActiveStepIsAnnounced() throws IOException {
List<String> problems = scan(STEP_TRIGGER, tag -> !tag.contains(":aria-current"));
assertTrue(problems.isEmpty(), "Harmonia's step indicator marks the active step visually only - "
+ "bind :aria-current=\"... ? 'step' : null\" on each trigger:\n" + String.join("\n", problems));
}

private static List<String> scan(Pattern tags, Predicate<String> isProblem) throws IOException {
List<String> problems = new ArrayList<>();
int templates = 0;
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
for (String pattern : TEMPLATE_PATTERNS) {
for (Resource template : resolver.getResources(pattern)) {
templates++;
Matcher tag = tags.matcher(template.getContentAsString(StandardCharsets.UTF_8));
while (tag.find()) {
if (isProblem.test(tag.group())) {
problems.add(template.getFilename() + ": " + tag.group());
}
}
}
}
assertTrue(templates > 20, "only " + templates + " Harmonia templates found on the classpath");
return problems;
}
}
Loading
Loading