chain-studio/test/a11y_test.dart
flemming-it c18bb7f357 fix(test): kill the a11y suite's pending-timer flake at its root
Finally caught with a creation stack trace: when the last gRPC
stream closes, Http2ClientConnection arms the channel's 5-minute
idleTimeout timer — even on a shut-down connection — so the
test's 1-minute drain never covered it and the framework's
pending-timer invariant tripped whenever the arm landed inside
the test window (frequent while a real hub listens on 50051).

The suite now closes the channel in real-async space at the end
of the body (new @visibleForTesting HubService.debugResetChannel;
shutdown is deliberately not awaited — it wedges on a mid-connect
socket, but cancels its timers synchronously) and pumps past the
idle timeout so the timer fires inside the test. 6 consecutive
full-suite runs + 3 isolated runs green; before, roughly 1 in 3
full runs failed.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-18 18:02:03 +02:00

111 lines
4.5 KiB
Dart

// Accessibility gate for every sidebar page, light AND dark:
//
// * textContrastGuideline — WCAG AA text contrast (>= 4.5:1 for
// normal text, >= 3:1 for large text) on whatever the page
// renders without a hub (empty/offline states included; those
// are exactly the states a fresh operator sees first).
// * labeledTapTargetGuideline — every tappable target exposes a
// semantic label, so icon-only buttons must carry a tooltip or
// Semantics label. Screen-reader users get a name for every
// action.
//
// The Android/iOS tap-target SIZE guidelines are deliberately not
// applied: Studio is a desktop app driven by pointer, and Flutter's
// desktop defaults (e.g. 34px DropdownMenu items) fail the 48px
// mobile rule by design.
//
// Navigation mirrors sidebar_test.dart: pump StudioApp directly,
// tap the sidebar destination keys, pump a fixed duration (the app
// holds long-lived timers, so pumpAndSettle never settles).
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/main.dart';
const _destinations = <String>[
'welcome',
'store',
'doctor',
'flows',
'audit',
'approvals',
'runs',
'federation',
];
void main() {
for (final mode in [ThemeModeValue.light, ThemeModeValue.dark]) {
testWidgets('all pages meet a11y guidelines — ${mode.name}',
(tester) async {
SharedPreferences.setMockInitialValues({});
// Layout correctness across sizes is responsive_test.dart's
// job; this suite audits colors and labels at a normal size.
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final handle = tester.ensureSemantics();
await tester.pumpWidget(
StudioApp(
initialThemeMode: mode,
initialLocale: const Locale('de'),
),
);
await tester.pump(const Duration(milliseconds: 100));
// Collect violations across ALL pages before failing, so one
// regression does not mask the rest of the sweep.
final violations = <String>[];
for (final id in _destinations) {
await tester.tap(find.byKey(ValueKey('sidebar-item-$id')));
await tester.pump(const Duration(milliseconds: 400));
for (final (guideline, what) in [
(textContrastGuideline, 'text contrast'),
(labeledTapTargetGuideline, 'unlabeled tap targets'),
]) {
final result = await guideline.evaluate(tester);
if (!result.passed) {
final reason = result.reason ?? '';
// Known evaluator false positive: the workspace switcher
// wraps its trigger in an OverlayPortal + Tooltip, which
// makes the contrast guideline lose the text render and
// measure the chip's own fill (#27272A) against the page
// canvas instead. The actual label text is onSurface on
// surfaceContainerHighest (>10:1). Skip exactly that node.
if (what == 'text contrast' &&
reason.contains('_OverlayPortalState') &&
reason.contains('Arbeitsbereich')) {
continue;
}
violations.add('page "$id" (${mode.name}) $what: $reason');
}
}
}
handle.dispose();
// Tear the app down explicitly and drain in-flight one-shot
// timers (status polls, tooltip delays) — the binding asserts
// !timersPending after the test body.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(minutes: 1));
// The suite's long-standing pending-timer flake, finally
// caught with a creation stack: when the last gRPC stream
// closes, Http2ClientConnection._handleActiveStateChanged
// arms the channel's 5-minute idleTimeout timer — even on a
// shut-down connection — so a 1-minute drain never covered
// it. Close the channel in real-async space (lets in-flight
// socket callbacks land), then pump PAST the idle timeout so
// the timer fires inside the test body.
await tester.runAsync(() async {
HubService.instance.debugResetChannel();
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pump(const Duration(minutes: 6));
expect(
violations,
isEmpty,
reason: 'a11y violations:\n${violations.join('\n\n')}',
);
});
}
}