fb7490f1df
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
An email link/code can be confirmed out of band: the recipient taps the one-tap link in the email, which confirms in another browser/session. The backend already publishes a `notify` `profile` re-fetch signal for this (handlers_auth.go handleEmailConfirmLink), and the client re-fetches on it. But the live stream is single-shot with no replay: a Mini App backgrounded while the user is in their mail app tapping the link drops the stream and misses the event (the gateway hub has no subscriber to deliver to), and the reconnect on foreground does not re-sync — so the open code form stayed until a manual reload. On Telegram Desktop the app is never backgrounded, so the push works and there was no bug. Add a client-side fallback: while an add-email confirmation is pending (a code was sent, no email yet), poll `profile.get` on a 4s interval and on foreground regain until the address lands; the effect stops as soon as the email appears. The live push still updates instantly when foregrounded — this only covers the backgrounded-miss gap. Tests: a mock e2e attaches the email WITHOUT emitting a live event (new window.__mock.clearEmail / confirmEmailOutOfBand seams), so it exercises the poll, not the push, and asserts the code form collapses into the email row. Docs: ARCHITECTURE.md §10 notes the single-shot gap + the poll fallback.
411 lines
21 KiB
TypeScript
411 lines
21 KiB
TypeScript
import { expect, test, type Page } from './fixtures';
|
|
|
|
// Social / account / history surfaces against the mock transport (no backend).
|
|
// The mock profile is a durable account, so friends, invitations, stats and the GCG
|
|
// export are reachable from the seeded fixture.
|
|
|
|
async function loginLobby(page: Page): Promise<void> {
|
|
await page.goto('/');
|
|
await page.getByRole('button', { name: /guest/i }).click();
|
|
await expect(page.getByText('Your turn')).toBeVisible();
|
|
}
|
|
|
|
// The Settings hub (the lobby ⚙️ tab) hosts Settings / Profile / Friends / About as in-place
|
|
// tabs; the back control always returns to the lobby. Tabs are icon-only with an aria-label.
|
|
async function openSettingsTab(page: Page, tab: 'Profile' | 'Friends' | 'Info'): Promise<void> {
|
|
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
|
|
await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle
|
|
await page.getByRole('button', { name: tab, exact: true }).click();
|
|
}
|
|
function openFriends(page: Page): Promise<void> {
|
|
return openSettingsTab(page, 'Friends');
|
|
}
|
|
function openProfile(page: Page): Promise<void> {
|
|
return openSettingsTab(page, 'Profile');
|
|
}
|
|
|
|
test('friends: issue a code, accept an incoming request, redeem a code', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openFriends(page);
|
|
|
|
// Issue a one-time code — it is shown to share, with a copy control.
|
|
await page.getByRole('button', { name: /Show my code/i }).click();
|
|
await expect(page.getByTestId('friend-code')).toContainText('246813');
|
|
await expect(page.getByRole('button', { name: 'Copy' })).toBeVisible();
|
|
|
|
// The seeded incoming request (Rick) can be accepted; the requests section clears.
|
|
await expect(page.getByText('Friend requests')).toBeVisible();
|
|
await page.getByRole('button', { name: /^Accept$/ }).click();
|
|
await expect(page.getByText('Friend requests')).toBeHidden();
|
|
|
|
// Redeeming a code adds a new friend to the list.
|
|
await page.locator('.codein').fill('111111');
|
|
await page.getByRole('button', { name: /^Add$/ }).click();
|
|
await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible();
|
|
});
|
|
|
|
test('friends: the row kebab reveals block/remove and an outside tap closes it', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openFriends(page);
|
|
|
|
// A friend row slides open on its kebab (like the lobby), exposing two icon actions.
|
|
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
|
|
await kaya.locator('.kebab').click();
|
|
await expect(kaya).toHaveClass(/revealed/);
|
|
await expect(kaya.locator('.acts').getByRole('button', { name: 'Block' })).toBeVisible();
|
|
await expect(kaya.locator('.acts').getByRole('button', { name: 'Remove' })).toBeVisible();
|
|
|
|
// A tap anywhere outside the action buttons collapses the row again.
|
|
await page.getByRole('heading', { name: 'Your friends' }).click();
|
|
await expect(kaya).not.toHaveClass(/revealed/);
|
|
});
|
|
|
|
test('friends: blocking from the list confirms (naming the friend) and moves them to Blocked', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openFriends(page);
|
|
|
|
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
|
|
await kaya.locator('.kebab').click();
|
|
await kaya.locator('.acts').getByRole('button', { name: 'Block' }).click();
|
|
|
|
// The confirmation keeps a generic title and names the friend in the body.
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog.getByText('Block this player?')).toBeVisible();
|
|
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
|
|
await dialog.getByRole('button', { name: 'Block' }).click();
|
|
|
|
// The block applied: Kaya leaves the friends list and shows under Blocked players.
|
|
await expect(page.getByText('No friends yet.')).toBeVisible();
|
|
const blocked = page.locator('.rowwrap', { hasText: 'Kaya' });
|
|
await expect(blocked.getByRole('button', { name: 'Unblock' })).toBeVisible();
|
|
});
|
|
|
|
test('friends: removing from the list confirms (naming the friend) and drops them', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openFriends(page);
|
|
|
|
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
|
|
await kaya.locator('.kebab').click();
|
|
await kaya.locator('.acts').getByRole('button', { name: 'Remove' }).click();
|
|
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog.getByText('Remove from friends?')).toBeVisible();
|
|
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
|
|
await dialog.getByRole('button', { name: 'Remove' }).click();
|
|
|
|
// Unfriending just drops the friendship — Kaya is gone and not blocked.
|
|
await expect(page.getByText('No friends yet.')).toBeVisible();
|
|
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toHaveCount(0);
|
|
});
|
|
|
|
test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await expect(page.getByText('Invitations')).toBeVisible();
|
|
await expect(page.getByText(/From Kaya/)).toBeVisible();
|
|
await expect(page.getByText('One word per turn')).toBeVisible(); // the single-word-rule line on the card
|
|
await page.getByRole('button', { name: /^Accept$/ }).click();
|
|
await expect(page.getByText(/From Kaya/)).toBeHidden();
|
|
});
|
|
|
|
test('stats screen shows the metrics and the per-variant best move', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Stats/ }).click();
|
|
await expect(page.getByText('Win rate')).toBeVisible();
|
|
await expect(page.getByText('Best move')).toBeVisible();
|
|
// The Moves count and the derived Hint share (12 hints / 248 plays = 4.8%, one decimal).
|
|
await expect(page.getByText('Moves', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('Hint share')).toBeVisible();
|
|
await expect(page.getByText('4.8%')).toBeVisible();
|
|
// The best move breaks down per played variant, each row labelled by the variant and
|
|
// ending in the play's score (the word itself renders as game tiles).
|
|
await expect(page.getByText('Scrabble', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('134')).toBeVisible();
|
|
});
|
|
|
|
test('settings hub: tabs switch in place and back returns to the lobby', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
|
|
await expect(page.locator('.pane')).toHaveCount(1);
|
|
await expect(page.locator('.seg').first()).toBeVisible(); // default Settings tab
|
|
|
|
// Switching tabs is in place (no navigation): the Friends body appears, still one pane.
|
|
await page.getByRole('button', { name: 'Friends', exact: true }).click();
|
|
await expect(page.getByText('Friend requests')).toBeVisible();
|
|
await expect(page.locator('.pane')).toHaveCount(1);
|
|
|
|
await page.getByRole('button', { name: 'Info', exact: true }).click();
|
|
await expect(page.getByText(/Version/)).toBeVisible();
|
|
|
|
// Back returns to the lobby from any tab.
|
|
await page.locator('.back').click();
|
|
await expect(page.getByText('Your turn')).toBeVisible();
|
|
});
|
|
|
|
test('profile edit saves a new display name', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openProfile(page);
|
|
await page.locator('.edit input').first().fill('Kaya Test');
|
|
await page.getByRole('button', { name: /^Save$/ }).click();
|
|
await expect(page.locator('.name')).toHaveText('Kaya Test');
|
|
});
|
|
|
|
test('GCG export appears only for a finished game', async ({ page }) => {
|
|
await loginLobby(page);
|
|
// The finished game vs Kaya exposes export 📤 in the history header.
|
|
await page.getByRole('button', { name: /Kaya/ }).click();
|
|
await page.locator('.scoreboard').click(); // open the history
|
|
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
|
});
|
|
|
|
test('GCG export is hidden for an active game', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Ann/ }).click();
|
|
await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export)
|
|
await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0);
|
|
});
|
|
|
|
test('finished game draws an inert footer and trims live-only controls', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya
|
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
|
// The footer (tab bar) is drawn but its controls are disabled in a finished game.
|
|
await expect(page.locator('.tab').first()).toBeDisabled();
|
|
// The history header offers Export GCG, not Drop game, once the game is over.
|
|
await page.locator('.scoreboard').click();
|
|
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
|
|
// The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
|
|
await page.getByRole('button', { name: 'Chat' }).click(); // 💬
|
|
await expect(page.locator('.pane')).toHaveCount(1);
|
|
await expect(page.getByRole('button', { name: 'Dictionary' })).toHaveCount(0);
|
|
// A finished game is read-only: neither the message field nor the nudge is offered.
|
|
await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
|
|
});
|
|
|
|
test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => {
|
|
await loginLobby(page);
|
|
|
|
// Both seeded finished games are listed; the active row carries the inert chevron, not a kebab.
|
|
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toBeVisible();
|
|
await expect(page.locator('.rowwrap', { hasText: 'Rick' })).toBeVisible();
|
|
const annRow = page.locator('.rowwrap', { hasText: 'Ann' });
|
|
await expect(annRow.locator('.kebab')).toHaveCount(0);
|
|
await expect(annRow.locator('.chev')).toBeVisible();
|
|
|
|
// The kebab reveals the delete action in place (no dropdown menu); tapping ❌ hides the game.
|
|
const kayaRow = page.locator('.rowwrap', { hasText: 'Kaya' });
|
|
await kayaRow.locator('.kebab').click();
|
|
await expect(kayaRow).toHaveClass(/revealed/);
|
|
await kayaRow.locator('.del').click();
|
|
|
|
// The hidden game is gone from the list; the other finished game remains.
|
|
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toHaveCount(0);
|
|
await expect(page.locator('.rowwrap', { hasText: 'Rick' })).toBeVisible();
|
|
});
|
|
|
|
test('lobby: the active-row chevron opens the game (not a no-op)', async ({ page }) => {
|
|
await loginLobby(page);
|
|
// The inert-looking '>' on an active row is a tap target that opens the game, like the row.
|
|
await page.locator('.rowwrap', { hasText: 'Ann' }).locator('.chev').click();
|
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
|
});
|
|
|
|
test('lobby ⚙️ tab shows the pending friend-request count', async ({ page }) => {
|
|
await loginLobby(page);
|
|
// The ⚙️ badge counts incoming friend requests only (Rick = 1); invitations have their
|
|
// own lobby section, so they are not summed into it.
|
|
await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toHaveText('1');
|
|
});
|
|
|
|
test('play with friends: a game type is required to send an invitation', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar (New Game tab, by its 🎲 icon — the label is themeable)
|
|
await page.getByRole('button', { name: 'Play with friends' }).click();
|
|
|
|
const send = page.getByRole('button', { name: 'Send invitation' });
|
|
await expect(send).toBeDisabled();
|
|
|
|
await page.getByRole('checkbox').first().check(); // pick a friend
|
|
await expect(send).toBeDisabled(); // still no game type
|
|
|
|
await page.locator('.field select').first().selectOption('scrabble_en');
|
|
await expect(send).toBeEnabled();
|
|
|
|
await send.click(); // the mock creates it and returns to the lobby
|
|
await expect(page.getByText('Your turn')).toBeVisible();
|
|
});
|
|
|
|
test('game: the add-friend 🤝 confirms with a tap and then reads as sent', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
|
|
await page.locator('.scoreboard').click(); // open the history -> 🤝 appears on Ann's card
|
|
const add = page.getByRole('button', { name: 'Add to friends' });
|
|
await add.click(); // arm: 🤝 -> a fading ✅
|
|
await add.click(); // tap the ✅ to confirm within the window
|
|
// The request is sent, so the control is now disabled.
|
|
await expect(add).toBeDisabled();
|
|
});
|
|
|
|
test('game: the block ✖️ confirms in red, then hides both controls and strikes the name', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
|
|
await page.locator('.scoreboard').click(); // open the history -> 🤝 and ✖️ appear on Ann's card
|
|
const block = page.getByRole('button', { name: 'Block player' });
|
|
await expect(block).toBeVisible();
|
|
await block.click(); // arm: ✖️ -> a fading ✅, the score caption turns to a red "Block?"
|
|
await expect(page.locator('.sc.blockprompt')).toHaveText('Block?');
|
|
// While confirming a block the add-friend control is hidden so the two never overlap.
|
|
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
|
|
await block.click(); // confirm within the window
|
|
// The block applied (optimistically): both controls disappear and the name is struck.
|
|
await expect(page.getByRole('button', { name: 'Block player' })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
|
|
await expect(page.locator('.nm.struck')).toBeVisible();
|
|
|
|
// The chat composer is hidden for a blocked opponent — only the log remains, as in a
|
|
// finished game.
|
|
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
|
await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
|
|
});
|
|
|
|
test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
|
|
await page.locator('.scoreboard').click(); // open the history
|
|
// Kaya is already a friend, so no add-friend control is offered on her card.
|
|
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
|
|
});
|
|
|
|
test('profile edit disables Save and flags an invalid display name', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openProfile(page);
|
|
|
|
const name = page.locator('.edit input').first();
|
|
const save = page.getByRole('button', { name: /^Save$/ });
|
|
await name.fill('Bad__Name'); // adjacent specials — invalid
|
|
await expect(save).toBeDisabled();
|
|
await expect(name).toHaveClass(/invalid/);
|
|
|
|
await name.fill('Good Name');
|
|
await expect(save).toBeEnabled();
|
|
});
|
|
|
|
// The profile's sign-in-methods matrix (email add/change + provider link/unlink). The mock
|
|
// profile is a durable account already holding an email, so the email control is the change
|
|
// flow; a new address containing "taken" stands in (in the mock) for one owned by another
|
|
// account, driving the non-disclosing refusal.
|
|
test('change email: a taken address is refused without disclosure', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openProfile(page);
|
|
|
|
await expect(page.getByText('you@example.com')).toBeVisible();
|
|
await page.getByRole('button', { name: 'Change' }).click();
|
|
await page.getByPlaceholder('New email address').fill('taken@example.com');
|
|
await page.getByRole('button', { name: 'Send code' }).click();
|
|
await page.locator('.accounts .codein').fill('123456');
|
|
await page.getByRole('button', { name: 'OK' }).click();
|
|
|
|
// The neutral message never reveals the other account.
|
|
await expect(page.getByText('Check the address or contact support.')).toBeVisible();
|
|
await expect(page.getByText(/belongs to another account/)).toHaveCount(0);
|
|
await expect(page.getByText('you@example.com')).toBeVisible();
|
|
});
|
|
|
|
test('change email: a free address replaces the current one', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openProfile(page);
|
|
|
|
await page.getByRole('button', { name: 'Change' }).click();
|
|
await page.getByPlaceholder('New email address').fill('fresh@example.com');
|
|
await page.getByRole('button', { name: 'Send code' }).click();
|
|
await page.locator('.accounts .codein').fill('123456');
|
|
await page.getByRole('button', { name: 'OK' }).click();
|
|
|
|
await expect(page.getByText('fresh@example.com')).toBeVisible();
|
|
await expect(page.getByText('you@example.com')).toHaveCount(0);
|
|
});
|
|
|
|
// The add-email confirmation can complete out of band: the recipient taps the one-tap link in the
|
|
// email, which confirms in another browser/session. A backgrounded Mini App misses the live
|
|
// 'profile' event (the stream is single-shot, no replay), so the open code form falls back to
|
|
// polling the profile until the address lands. The mock attaches the email WITHOUT emitting an
|
|
// event, so only the poll can surface it.
|
|
test('add email: an out-of-band confirmation surfaces on the open form via polling', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openProfile(page);
|
|
|
|
// Drop the seeded email so the sign-in section offers the add-email flow.
|
|
await page.evaluate(() => (window as unknown as { __mock: { clearEmail(): void } }).__mock.clearEmail());
|
|
const emailInput = page.locator('.accounts input[type="email"]');
|
|
await expect(emailInput).toBeVisible();
|
|
|
|
await emailInput.fill('linked@example.com');
|
|
await page.getByRole('button', { name: 'Send code' }).click();
|
|
await expect(page.locator('.accounts .codein')).toBeVisible();
|
|
|
|
// Confirmed elsewhere via the one-tap link, with no live event delivered: only the poll surfaces it.
|
|
await page.evaluate(() =>
|
|
(
|
|
window as unknown as { __mock: { confirmEmailOutOfBand(email: string): void } }
|
|
).__mock.confirmEmailOutOfBand('linked@example.com'),
|
|
);
|
|
|
|
// The confirmed address surfaces as the email row, and the code form is gone.
|
|
await expect(page.locator('.acctrow').filter({ hasText: 'linked@example.com' })).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.accounts .codein')).toHaveCount(0);
|
|
});
|
|
|
|
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openProfile(page);
|
|
|
|
// On the web the Telegram login-widget control is offered; the mock links it instantly.
|
|
await page.getByRole('button', { name: 'Link Telegram' }).click();
|
|
const tgRow = page.locator('.acctrow').filter({ hasText: 'Telegram' });
|
|
await expect(tgRow).toBeVisible();
|
|
|
|
// With email + Telegram two methods remain, so Unlink is offered; confirm it in the dialog.
|
|
await tgRow.getByRole('button', { name: 'Unlink' }).click();
|
|
await expect(page.getByText('Unlink account?')).toBeVisible();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Unlink' }).click();
|
|
|
|
await expect(page.locator('.acctrow').filter({ hasText: 'Telegram' })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
|
|
});
|
|
|
|
test('account deletion: the mailed-code step-up leads to the terminal deleted screen', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await openProfile(page);
|
|
|
|
// The mock profile holds an email, so deletion asks for the mailed code.
|
|
await page.getByRole('button', { name: 'Delete account' }).click();
|
|
await expect(page.getByText('Delete your account?')).toBeVisible();
|
|
await page.getByRole('dialog').locator('.codein').fill('123456');
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click();
|
|
|
|
// The app swaps to the terminal account-deleted screen.
|
|
await expect(page.getByText('Account deleted')).toBeVisible();
|
|
});
|
|
|
|
test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => {
|
|
await loginLobby(page);
|
|
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
|
|
await page.locator('.scoreboard').click(); // open the history
|
|
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
|
await expect(page.locator('.pane')).toHaveCount(1);
|
|
// On your turn the message field + Send are shown and the nudge is hidden;
|
|
// chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
|
|
// through the aria-label.
|
|
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
|
|
// The limit is one message per turn: after sending, the field is hidden behind a caption.
|
|
// It is still your turn, so the nudge does not appear either.
|
|
await page.getByPlaceholder(/Quick message/).fill('hi there');
|
|
await page.getByRole('button', { name: 'Send' }).click();
|
|
await expect(page.getByText('You can write again next turn.')).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
|
|
});
|