Merge pull request 'fix(auth): Semantics-Knoten im breiten Layout trennen (a11y Web)' (#48) from fix/41-semantics-split into main
Reviewed-on: #48
This commit is contained in:
commit
455b3bf708
11 changed files with 281 additions and 25 deletions
|
|
@ -7,6 +7,10 @@ import 'package:shuffle/core/theme/app_spacing.dart';
|
|||
/// centered card of at most [maxWidth] (default
|
||||
/// [AppBreakpoints.authCardMaxWidth]). The body scrolls, the keyboard pushes
|
||||
/// the content up (Scaffold resizes) and drags dismiss the keyboard.
|
||||
///
|
||||
/// Semantics: the card keeps its children as separate semantics nodes
|
||||
/// (`explicitChildNodes`), so heading, text and buttons stay individually
|
||||
/// reachable (see `docs/design/auth-screens.md`).
|
||||
class AppFormScaffold extends StatelessWidget {
|
||||
const AppFormScaffold({
|
||||
required this.child,
|
||||
|
|
@ -30,10 +34,19 @@ class AppFormScaffold extends StatelessWidget {
|
|||
final carded = width >= AppBreakpoints.medium;
|
||||
final content = carded
|
||||
? Card(
|
||||
// Card is a semantics container; without explicit child
|
||||
// nodes it folds title, text and buttons of the page into
|
||||
// ONE node (heading + button in one), so screen readers
|
||||
// find no button (issue #41).
|
||||
// Alternative: Card(semanticContainer: false); the explicit
|
||||
// wrapper keeps the Card's own container semantics.
|
||||
child: Semantics(
|
||||
explicitChildNodes: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
)
|
||||
: child;
|
||||
return SingleChildScrollView(
|
||||
|
|
|
|||
|
|
@ -124,3 +124,49 @@ List<String> visibleTexts(WidgetTester tester) => [
|
|||
for (final text in tester.widgetList<Text>(find.byType(Text)))
|
||||
if (text.data != null) text.data!,
|
||||
];
|
||||
|
||||
/// Wide layout (>= 600 dp): the screen sits in a card (issue #41).
|
||||
const wideScreenSize = Size(1280, 720);
|
||||
|
||||
/// Asserts that [target] is an enabled button node of its own with [label].
|
||||
///
|
||||
/// `isSemantics` only checks the given properties, so extra flags
|
||||
/// (focusable, ...) added by future Flutter versions do not break it.
|
||||
void expectButtonSemantics(WidgetTester tester, Finder target, String label) {
|
||||
expect(
|
||||
tester.getSemantics(target),
|
||||
isSemantics(
|
||||
label: label,
|
||||
isButton: true,
|
||||
isEnabled: true,
|
||||
hasEnabledState: true,
|
||||
hasTapAction: true,
|
||||
),
|
||||
reason: 'button must be a button node of its own',
|
||||
);
|
||||
}
|
||||
|
||||
/// Asserts that title, body text and button are separate semantics nodes
|
||||
/// (heading / text / button with label), not folded into one node (issue #41).
|
||||
void expectSeparateSemantics(
|
||||
WidgetTester tester, {
|
||||
required String title,
|
||||
required Key buttonKey,
|
||||
required String buttonLabel,
|
||||
Key? bodyKey,
|
||||
String? bodyLabel,
|
||||
}) {
|
||||
expect(
|
||||
tester.getSemantics(find.text(title).first),
|
||||
isSemantics(label: title, isHeader: true, isButton: false),
|
||||
reason: 'title must be a heading of its own',
|
||||
);
|
||||
if (bodyKey != null) {
|
||||
expect(
|
||||
tester.getSemantics(find.byKey(bodyKey)),
|
||||
isSemantics(label: bodyLabel, isHeader: false, isButton: false),
|
||||
reason: 'body text must be a text node of its own',
|
||||
);
|
||||
}
|
||||
expectButtonSemantics(tester, find.byKey(buttonKey), buttonLabel);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,6 +290,72 @@ void main() {
|
|||
expect(currentPath(router), '/login');
|
||||
});
|
||||
|
||||
group('QUE-06 (issue #41): separate semantics nodes in the wide card', () {
|
||||
testWidgets('narrow layout: heading, text and resend button', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await open(tester);
|
||||
|
||||
expectSeparateSemantics(
|
||||
tester,
|
||||
title: _de.authCheckEmailTitle,
|
||||
bodyKey: const Key('check_email_body'),
|
||||
bodyLabel: _de.authCheckEmailBody,
|
||||
buttonKey: const Key('check_email_resend'),
|
||||
buttonLabel: _de.authCheckEmailResend,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('confirmation: heading, text and resend button', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await open(tester, size: wideScreenSize);
|
||||
|
||||
expectSeparateSemantics(
|
||||
tester,
|
||||
title: _de.authCheckEmailTitle,
|
||||
bodyKey: const Key('check_email_body'),
|
||||
bodyLabel: _de.authCheckEmailBody,
|
||||
buttonKey: const Key('check_email_resend'),
|
||||
buttonLabel: _de.authCheckEmailResend,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('reset: heading, text and secondary buttons', (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await pumpAuthRoute(
|
||||
tester,
|
||||
repository,
|
||||
location: '/forgot-password/check-email',
|
||||
size: wideScreenSize,
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.getSemantics(find.text(_de.authCheckEmailTitle).first),
|
||||
isSemantics(label: _de.authCheckEmailTitle, isHeader: true),
|
||||
);
|
||||
expect(
|
||||
tester.getSemantics(find.byKey(const Key('check_email_body'))),
|
||||
isSemantics(label: _de.authCheckEmailBodyReset, isButton: false),
|
||||
);
|
||||
expectButtonSemantics(
|
||||
tester,
|
||||
find.byKey(const Key('check_email_different_address')),
|
||||
_de.authCheckEmailDifferentAddress,
|
||||
);
|
||||
expectButtonSemantics(
|
||||
tester,
|
||||
find.byKey(const Key('check_email_back_to_login')),
|
||||
_de.authBackToLogin,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('QUE-05 Kriterium 2: english texts', (tester) async {
|
||||
await open(tester, locale: const Locale('en'));
|
||||
|
||||
|
|
|
|||
|
|
@ -263,6 +263,107 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('QUE-06 (issue #41): separate semantics nodes in the wide card', () {
|
||||
testWidgets('ready: heading, text and button are separate nodes', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await open(tester, size: wideScreenSize);
|
||||
|
||||
expectSeparateSemantics(
|
||||
tester,
|
||||
title: _de.authConfirmTitle,
|
||||
bodyKey: const Key('email_link_body'),
|
||||
bodyLabel: _de.authConfirmBody,
|
||||
buttonKey: const Key('email_link_submit'),
|
||||
buttonLabel: _de.authConfirmSubmit,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('recovery: heading, text and button are separate nodes', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await open(
|
||||
tester,
|
||||
location: '/auth/confirm?token_hash=abc123&type=recovery',
|
||||
size: wideScreenSize,
|
||||
);
|
||||
|
||||
expectSeparateSemantics(
|
||||
tester,
|
||||
title: _de.authRecoveryTitle,
|
||||
bodyKey: const Key('email_link_body'),
|
||||
bodyLabel: _de.authRecoveryBody,
|
||||
buttonKey: const Key('email_link_submit'),
|
||||
buttonLabel: _de.authRecoverySubmit,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
// Checks the banner structure (live region + action button as own
|
||||
// nodes). This also holds without the card fix, because AppBanner is a
|
||||
// semantics container itself; the folding tests are the ones above.
|
||||
testWidgets('invalid link: banner live region and action are own nodes', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await open(
|
||||
tester,
|
||||
location: '/auth/confirm?token_hash=x&type=bad',
|
||||
size: wideScreenSize,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.bySemanticsLabel('Fehler: ${_de.authErrorLinkInvalidOrExpired}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expectButtonSemantics(
|
||||
tester,
|
||||
find.text(_de.authActionRequestNewLink),
|
||||
_de.authActionRequestNewLink,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('narrow layout: heading, text and button are separate nodes', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await open(tester);
|
||||
|
||||
expectSeparateSemantics(
|
||||
tester,
|
||||
title: _de.authConfirmTitle,
|
||||
bodyKey: const Key('email_link_body'),
|
||||
bodyLabel: _de.authConfirmBody,
|
||||
buttonKey: const Key('email_link_submit'),
|
||||
buttonLabel: _de.authConfirmSubmit,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('success: banner stays a live region, button is separate', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await open(tester, size: wideScreenSize);
|
||||
await tapSubmit(tester);
|
||||
|
||||
expect(
|
||||
find.bySemanticsLabel('Hinweis: ${_de.authConfirmSuccess}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expectButtonSemantics(
|
||||
tester,
|
||||
find.byKey(const Key('email_link_continue')),
|
||||
_de.authConfirmContinue,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('QUE-05 Kriterium 2: english texts', (tester) async {
|
||||
await open(tester, locale: const Locale('en'));
|
||||
|
||||
|
|
|
|||
|
|
@ -375,6 +375,29 @@ void main() {
|
|||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('success view: heading, live region and button (issue #41)', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
when(() => repository.currentStatus).thenReturn(_changed);
|
||||
await open(tester, size: wideScreenSize);
|
||||
|
||||
expect(
|
||||
tester.getSemantics(find.text(_de.authResetTitle).first),
|
||||
isSemantics(label: _de.authResetTitle, isHeader: true),
|
||||
);
|
||||
expect(
|
||||
find.bySemanticsLabel('Hinweis: ${_de.authPasswordChanged}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expectButtonSemantics(
|
||||
tester,
|
||||
find.byKey(const Key('reset_password_continue')),
|
||||
_de.authConfirmContinue,
|
||||
);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('text scale 2.0 on a small phone does not overflow', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,20 @@ Schlüssel sind `lowerCamelCase` mit Präfix `auth`.
|
|||
Fokus am Knopf, das Banner wird als Live-Region angesagt.
|
||||
- Enter im letzten Feld sendet ab (`TextInputAction.done`), sonst `next`.
|
||||
|
||||
### Semantics-Struktur (Regel, Issue #41)
|
||||
|
||||
- Überschrift, Text und Knopf sind **getrennte Semantics-Knoten**: `heading` (`AuthScreenTitle`), Text, `button`
|
||||
mit Label (`AppPrimaryButton`, `TextButton`). Das gilt in jeder Breite, auch in der Karte ab 600 dp.
|
||||
- Ursache des Fehlers: `Card` ist ein Semantics-Container ohne `explicitChildNodes`. Alle Kinder ohne eigenen
|
||||
Container (Titel, `Text`, Knopf) werden in dessen einen Knoten gefaltet. Seiten mit Eingabefeld fielen nicht auf,
|
||||
weil `AppTextField` & Co. eigene Container sind. Der Fehler trat auf allen Plattformen ab 600 dp (Karte) auf und wurde im E2E (Web) sichtbar; auf dem Handy ohne Karte nicht.
|
||||
- Deshalb kapselt `AppFormScaffold` den Karteninhalt in `Semantics(explicitChildNodes: true)`. Neue Layouts mit
|
||||
`Card`/`Semantics(container: true)` um Seiteninhalt müssen das ebenfalls tun; `MergeSemantics` nur gezielt für
|
||||
zusammengehörige Einzelelemente, nie um ganze Seiten.
|
||||
- Banner bleiben eigene Live-Regionen (`AppBanner`), ihre Aktion ist ein eigener Knopf-Knoten.
|
||||
- Test: `expectSeparateSemantics` (`app/test/features/auth/presentation/auth_ui_support.dart`) im weiten Layout
|
||||
(`wideScreenSize`); E2E klickt per `getByRole('button', …)`.
|
||||
|
||||
Legende der Skizzen: `[ ]` Feld, `[x]` Checkbox, `( )` Primärknopf, `<…>` Link/TextButton, `!` Banner.
|
||||
|
||||
## 2. Skizzen
|
||||
|
|
|
|||
|
|
@ -515,7 +515,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
|
|||
### 2026-11-03 – T3 (Issue #38, Branch `feature/38-auth-e2e`)
|
||||
|
||||
1. **7.1 Krit. 9 (web), Rezept korrigiert:** Das Setzen von `expires_at` im `localStorage` erzwingt keinen Refresh, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird. Der Test verschiebt stattdessen die Browser-Uhr (`Date`) um 2 h (`shiftBrowserClock` in `e2e/support/web.ts`); der Server rechnet mit der echten Zeit.
|
||||
2. **A11y-Befund (QUE-06):** Auf `/auth/confirm` und der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (`pressFirstButton`).
|
||||
2. **A11y-Befund (QUE-06):** Auf `/auth/confirm` und der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (`pressFirstButton`). **Behoben in #41:** `AppFormScaffold` kapselt den Karteninhalt mit `explicitChildNodes`, die E2E-Tests klicken über `getByRole('button')` (`pressFirstButton` entfällt).
|
||||
|
||||
### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris
|
||||
|
||||
|
|
@ -529,3 +529,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
|
|||
1. **Entscheidung Chris (Option 1):** Bei `recover`, `resend` und `signUp` zeigt die App für jeden Fehler, der weder Eingabe- noch Transportfehler noch IP-Drosselung ist (insbesondere HTTP 5xx / `unexpected_failure`), dieselbe neutrale Seite wie bei Erfolg. Umsetzung als Allowlist in `openEmailInputFailureFor` und `resendFailureFor`; der Mapper liefert 5xx mit Status bei diesen Aufrufen als `unexpected` (statt `network`), ein `signUp`-500 ohne bekannte Trigger-Meldung ebenfalls (statt `registrationRejected`).
|
||||
2. **Login-Resend (Krit. 6), Entscheidung Orchestrator (S4, Chris wird informiert):** Dort bleibt `unexpected` **sichtbar** (allgemeiner Fehler mit „Erneut versuchen“), weil das Konto durch das richtige Passwort bereits bewiesen ist und Neutralisieren nur einen echten SMTP-Ausfall verdecken würde. `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12). Neutral bleibt es auf CheckEmail-Resend, Forgot und Register. `registrationRejected` ist nur mit bekanntem Grund sichtbar.
|
||||
3. **4.4, R17, F13** ergänzt; Betriebshinweis in `docs/auth-konfiguration.md`.
|
||||
|
||||
### 2026-11-03 – A11y-Fix Semantics-Split (Issue #41, Branch `fix/41-semantics-split`)
|
||||
|
||||
1. **Ursache und Fix:** Die `Card` im Layout ab 600 dp faltete Titel, Text und Knopf in einen Semantics-Knoten. `AppFormScaffold` setzt `Semantics(explicitChildNodes: true)` um den Karteninhalt (Regel in `docs/design/auth-screens.md`). Der Befund aus T3 (A11y-Befund QUE-06) ist damit behoben; `pressFirstButton` ist entfernt.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export const t = {
|
|||
termsNotAccepted: 'Please agree to the privacy policy and the terms of use.',
|
||||
confirmSuccess: 'Email confirmed.',
|
||||
passwordChanged: 'Password changed. Other devices will be signed out; this can take up to an hour.',
|
||||
confirmSubmit: 'Confirm email address',
|
||||
recoverySubmit: 'Continue to new password',
|
||||
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
|
||||
} as const;
|
||||
|
||||
|
|
@ -42,19 +44,6 @@ export async function enableSemantics(page: Page): Promise<void> {
|
|||
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bedient die Schaltflaeche auf Seiten ohne Eingabefeld (Link-Seite, CheckEmail) per Tastatur.
|
||||
*
|
||||
* BEFUND (a11y, QUE-06): Auf diesen Seiten faltet der Semantics-Baum Titel, Text und Knopf zu
|
||||
* EINEM `heading` (bzw. `button`) zusammen; ein eigener `button`-Knoten fehlt, ein Klick auf
|
||||
* die Mitte bewirkt nichts. Per Tab + Enter geht es. Wird die App korrigiert, kann der Helfer
|
||||
* auf `getByRole('button', ...)` umgestellt werden.
|
||||
*/
|
||||
export async function pressFirstButton(page: Page, tabs = 1): Promise<void> {
|
||||
for (let i = 0; i < tabs; i++) await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
/** Navigiert und schaltet Semantics ein. */
|
||||
export async function open(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { subjects } from '../../support/auth-config';
|
|||
import { profileByEmail } from '../../support/db';
|
||||
import { randomEmail, randomPassword } from '../../support/gotrue';
|
||||
import { extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||
import { expectSignedIn, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
|
||||
import { expectSignedIn, login, open, register, t, textOf } from '../../support/web';
|
||||
|
||||
test.describe('KTO-01 Kriterium 1 (web): Registrierung bis „E-Mail bestaetigt“ und Anmeldung', () => {
|
||||
test('KTO-01 Kriterium 1: Registrieren, Link in der Mail oeffnen, bestaetigen, anmelden', async ({ page }) => {
|
||||
|
|
@ -21,7 +21,7 @@ test.describe('KTO-01 Kriterium 1 (web): Registrierung bis „E-Mail bestaetigt
|
|||
|
||||
// Der Link aus der Mail fuehrt auf die App; erst der Klick loest die Pruefung aus (Spec E3).
|
||||
await open(page, link.pathname + link.search);
|
||||
await pressFirstButton(page);
|
||||
await page.getByRole('button', { name: t.confirmSubmit }).click();
|
||||
await expect(textOf(page, t.confirmSuccess)).toBeVisible();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
|
||||
import { expectMailCount, waitForMail } from '../../support/mailpit';
|
||||
import { freshBrowserContext, fillField, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
|
||||
import { freshBrowserContext, fillField, login, open, register, t, textOf } from '../../support/web';
|
||||
|
||||
const gotrue = new GoTrueClient();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { subjects } from '../../support/auth-config';
|
|||
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
|
||||
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||
import { freshBrowserContext, fillField, open, pressFirstButton, t, textOf } from '../../support/web';
|
||||
import { freshBrowserContext, fillField, open, t, textOf } from '../../support/web';
|
||||
|
||||
const gotrue = new GoTrueClient();
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
|
|||
|
||||
// Der Link fuehrt auf die Link-Seite; erst der Klick loest die Pruefung aus.
|
||||
await open(page, link.pathname + link.search);
|
||||
await pressFirstButton(page);
|
||||
await page.getByRole('button', { name: t.recoverySubmit }).click();
|
||||
await expect(page).toHaveURL(/\/reset-password$/);
|
||||
|
||||
await fillField(page.getByRole('textbox', { name: 'New password' }), newPassword);
|
||||
|
|
@ -48,7 +48,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
|
|||
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200); // Link verbraucht
|
||||
|
||||
await open(page, link.pathname + link.search);
|
||||
await pressFirstButton(page);
|
||||
await page.getByRole('button', { name: t.recoverySubmit }).click();
|
||||
await expect(textOf(page, t.linkInvalid)).toBeVisible();
|
||||
});
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
|
|||
await expireRecoveryLink(email);
|
||||
|
||||
await open(page, link.pathname + link.search);
|
||||
await pressFirstButton(page);
|
||||
await page.getByRole('button', { name: t.recoverySubmit }).click();
|
||||
await expect(textOf(page, t.linkInvalid)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Request a new link' })).toBeVisible();
|
||||
});
|
||||
|
|
@ -72,7 +72,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
|
|||
await expireConfirmationLink(email);
|
||||
|
||||
await open(page, link.pathname + link.search);
|
||||
await pressFirstButton(page);
|
||||
await page.getByRole('button', { name: t.confirmSubmit }).click();
|
||||
await expect(textOf(page, t.linkInvalid)).toBeVisible();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue