mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
test(e2e): drive both modes through a real browser
Twenty-one Playwright specs against the real binary serving the real bundle: a bot game with its rejections, an online game across two browser contexts, and the reconnect path. The server plays against the fixture dictionary, so nothing here downloads the upstream release. Every wait is on a state the server produced — the turn indicator, the required syllable, a word appearing in the chain — never a sleep. Moves are chosen by reading the same wordlist the server has rather than scripting a fixed sequence, because the bot is entitled to pick a different reply. Two things that do not work are recorded where the next person will look. Offline emulation does not cut a loopback socket, so a test that went offline kept playing happily. Routing the socket cuts it for real, but the close does not reliably reach the server, so the grace window never starts: those tests take the page away instead.
This commit is contained in:
1 parent
ffa13a0f6e
commit
e162574556
7 files changed
+733
No files matched your search
@@ -0,0 +1,173 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { ONE_SYLLABLE_WORD, UNKNOWN_WORD } from './fixture-dictionary.js';
|
||||
import {
|
||||
board,
|
||||
chainWords,
|
||||
chooseDifficulty,
|
||||
playLegalMove,
|
||||
setNickname,
|
||||
submitWord,
|
||||
waitForMyTurn
|
||||
} from './helpers.js';
|
||||
|
||||
test.describe('playing the bot', () => {
|
||||
test('a game runs end to end at the hardest difficulty', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await setNickname(page, 'Minh');
|
||||
await chooseDifficulty(page, /Khó/);
|
||||
await page.getByRole('button', { name: 'Chơi với máy' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/play\?difficulty=3/);
|
||||
await waitForMyTurn(page);
|
||||
|
||||
// The fixture graph has one syllable productive enough to open on, so
|
||||
// the opening word always ends in it.
|
||||
await expect(board(page).syllable).toHaveText('sinh');
|
||||
|
||||
const used = new Set(await chainWords(page));
|
||||
const mine = await playLegalMove(page, used);
|
||||
|
||||
// The bot answers, which is the only thing that moves the turn back.
|
||||
await waitForMyTurn(page);
|
||||
|
||||
const words = await chainWords(page);
|
||||
expect(words).toContain(mine);
|
||||
expect(words.length).toBeGreaterThanOrEqual(3); // opening, mine, the bot's
|
||||
});
|
||||
|
||||
test('the score rises and the board shows both sides', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
const used = new Set(await chainWords(page));
|
||||
await playLegalMove(page, used);
|
||||
await waitForMyTurn(page);
|
||||
|
||||
// Scores are the server's; the client only renders them.
|
||||
const scores = await page.locator('.score').allTextContents();
|
||||
expect(scores).toHaveLength(2);
|
||||
expect(Number(scores[0])).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test.describe('rejections', () => {
|
||||
test('a word outside the dictionary is refused in Vietnamese', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
await submitWord(page, UNKNOWN_WORD);
|
||||
|
||||
await expect(board(page).rejection).toHaveText('Không tìm thấy từ này trong từ điển.');
|
||||
});
|
||||
|
||||
test('a single syllable is refused for being too short', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
await submitWord(page, ONE_SYLLABLE_WORD);
|
||||
|
||||
await expect(board(page).rejection).toHaveText('Từ phải có ít nhất 2 tiếng.');
|
||||
});
|
||||
|
||||
test('a real word that does not link names the syllable it should start with', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
// "toán học" is in the fixture but never starts with the opening
|
||||
// syllable, so this is a wrong link rather than an unknown word.
|
||||
await submitWord(page, 'toán học');
|
||||
|
||||
await expect(board(page).rejection).toHaveText('Từ phải bắt đầu bằng tiếng “sinh”.');
|
||||
});
|
||||
|
||||
test('a refused word costs the player the attempt, not the turn', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
await submitWord(page, UNKNOWN_WORD);
|
||||
await expect(board(page).rejection).toBeVisible();
|
||||
|
||||
// The word reached the server, so the field clears. What must not
|
||||
// happen is the turn moving on, and the player must be able to try
|
||||
// again immediately.
|
||||
await expect(board(page).input).toHaveValue('');
|
||||
await expect(board(page).turn).toHaveText('Đến lượt bạn');
|
||||
await expect(board(page).input).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('resigning ends the game and offers another', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a rematch starts exactly one new game', async ({ page }) => {
|
||||
// The regression this guards: the screen used to infer "start a game"
|
||||
// from the board being empty, so clearing it for a rematch sent a second
|
||||
// request and the server built two rooms that destroyed each other.
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Chơi lại' }).click();
|
||||
await waitForMyTurn(page);
|
||||
|
||||
// One game means one opening word on the board.
|
||||
const words = await chainWords(page);
|
||||
expect(words).toHaveLength(1);
|
||||
|
||||
// And it stays one game: a second room would take the turn away again
|
||||
// or reject the next move as stale.
|
||||
const used = new Set(words);
|
||||
await playLegalMove(page, used);
|
||||
await waitForMyTurn(page);
|
||||
await expect(board(page).rejection).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the personal best is kept per difficulty', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=2');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
const used = new Set(await chainWords(page));
|
||||
await playLegalMove(page, used);
|
||||
await waitForMyTurn(page);
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Về trang chủ' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Về trang chủ' }).click();
|
||||
|
||||
// The record shows against Trung bình, and only against it.
|
||||
const medium = page.getByRole('radio', { name: /Trung bình/ });
|
||||
await expect(medium).toBeVisible();
|
||||
await expect(page.locator('label', { has: medium })).not.toContainText('Chưa có');
|
||||
await expect(
|
||||
page.locator('label', { has: page.getByRole('radio', { name: /^Dễ/ }) })
|
||||
).toContainText('Chưa có');
|
||||
});
|
||||
|
||||
test('the attribution footer credits the dictionary source', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const link = page.getByRole('link', { name: 'minhqnd/dictionary' });
|
||||
await expect(link).toHaveAttribute('href', 'https://github.com/minhqnd/dictionary');
|
||||
await expect(page.getByRole('link', { name: /CC BY-SA 4\.0/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a deep link is served by the binary, not a 404', async ({ page }) => {
|
||||
const response = await page.goto('/play?difficulty=3');
|
||||
expect(response?.status()).toBe(200);
|
||||
await waitForMyTurn(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* The same word list the server is playing against, read so a test can answer
|
||||
* whatever syllable it is shown.
|
||||
*
|
||||
* Scripting a fixed sequence of words would not survive the bot choosing a
|
||||
* different reply, and the bot is allowed to. Reading the graph instead lets a
|
||||
* test assert what matters — that a legal word is accepted and an illegal one
|
||||
* is refused — without depending on which legal word the opponent picked.
|
||||
*/
|
||||
const listPath = fileURLToPath(new URL('../../testdata/fixture-words.txt', import.meta.url));
|
||||
|
||||
const words = readFileSync(listPath, 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'));
|
||||
|
||||
/** @type {Map<string, string[]>} */
|
||||
const byFirstSyllable = new Map();
|
||||
for (const word of words) {
|
||||
const first = word.split(/\s+/)[0];
|
||||
const list = byFirstSyllable.get(first) ?? [];
|
||||
list.push(word);
|
||||
byFirstSyllable.set(first, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legal continuation of `syllable` that has not been played yet.
|
||||
*
|
||||
* @param {string} syllable
|
||||
* @param {Set<string>} used
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function continuation(syllable, used) {
|
||||
return (byFirstSyllable.get(syllable) ?? []).find((word) => !used.has(word));
|
||||
}
|
||||
|
||||
/**
|
||||
* A word that is not in the dictionary at all. Vietnamese-shaped so it passes
|
||||
* the syllable-count rule and is refused for the reason the test is about.
|
||||
*/
|
||||
export const UNKNOWN_WORD = 'xoăn xoét';
|
||||
|
||||
/** A single-syllable input, which the rules refuse before any lookup. */
|
||||
export const ONE_SYLLABLE_WORD = 'sinh';
|
||||
|
||||
/**
|
||||
* The syllable every game opens on, and how many words continue it.
|
||||
*
|
||||
* The server will only open on a word whose last syllable has at least 20
|
||||
* continuations (minOpeningOutDegree in room.go). Exactly one syllable in this
|
||||
* fixture clears that, which is what makes a scripted game deterministic — and
|
||||
* what makes deleting a few words from the list break every browser test with
|
||||
* an unhelpful "game_start_failed". Asserting it here turns that into a
|
||||
* sentence a reader can act on.
|
||||
*/
|
||||
export const HUB_SYLLABLE = 'sinh';
|
||||
const MIN_OPENING_OUT_DEGREE = 20;
|
||||
|
||||
const hubContinuations = (byFirstSyllable.get(HUB_SYLLABLE) ?? []).length;
|
||||
if (hubContinuations < MIN_OPENING_OUT_DEGREE) {
|
||||
throw new Error(
|
||||
`testdata/fixture-words.txt has ${hubContinuations} words starting with "${HUB_SYLLABLE}"; ` +
|
||||
`the server needs ${MIN_OPENING_OUT_DEGREE} before it will open a game on that syllable`
|
||||
);
|
||||
}
|
||||
|
||||
export { words };
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { continuation } from './fixture-dictionary.js';
|
||||
|
||||
/**
|
||||
* Shared page driving for the end-to-end specs.
|
||||
*
|
||||
* Everything here waits on a state the server produced — the turn indicator,
|
||||
* the syllable, a word appearing in the chain — rather than on a fixed delay.
|
||||
* A sleep would pass or fail depending on how loaded the machine is, which is
|
||||
* the usual reason a browser suite goes intermittently red.
|
||||
*/
|
||||
|
||||
/** @param {import('@playwright/test').Page} page */
|
||||
export function board(page) {
|
||||
return {
|
||||
syllable: page.getByTestId('current-syllable'),
|
||||
turn: page.getByTestId('turn-indicator'),
|
||||
input: page.getByRole('textbox', { name: 'Nhập từ của bạn' }),
|
||||
submit: page.getByRole('button', { name: 'Gửi', exact: true }),
|
||||
chain: page.getByRole('list').first(),
|
||||
rejection: page.getByRole('alert')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until it is this player's turn to move.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export async function waitForMyTurn(page) {
|
||||
await expect(board(page).turn).toHaveText('Đến lượt bạn', { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Types a word and sends it. The field is uncontrolled on purpose, so this
|
||||
* fills and submits exactly as a player would.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} word
|
||||
*/
|
||||
export async function submitWord(page, word) {
|
||||
const b = board(page);
|
||||
await b.input.fill(word);
|
||||
await b.submit.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays one legal move for whatever syllable is currently required, and returns
|
||||
* the word it played.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {Set<string>} used
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function playLegalMove(page, used) {
|
||||
await waitForMyTurn(page);
|
||||
|
||||
const syllable = (await board(page).syllable.textContent())?.trim() ?? '';
|
||||
const word = continuation(syllable, used);
|
||||
expect(word, `the fixture has no unused continuation of "${syllable}"`).toBeTruthy();
|
||||
|
||||
used.add(/** @type {string} */ (word));
|
||||
await submitWord(page, /** @type {string} */ (word));
|
||||
await expect(page.getByText(/** @type {string} */ (word), { exact: true }).first()).toBeVisible();
|
||||
return /** @type {string} */ (word);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a difficulty by clicking its card.
|
||||
*
|
||||
* The radio itself is visually hidden so the platform supplies arrow-key
|
||||
* behaviour, so the card is what a player actually clicks.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string | RegExp} label
|
||||
*/
|
||||
export async function chooseDifficulty(page, label) {
|
||||
await page.locator('label', { has: page.getByRole('radio', { name: label }) }).click();
|
||||
await expect(page.getByRole('radio', { name: label })).toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the nickname on the home screen.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function setNickname(page, name) {
|
||||
await page.getByLabel('Tên của bạn').fill(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the words currently in the chain, opening word included.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function chainWords(page) {
|
||||
return page.locator('ol li .word').allTextContents();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
|
||||
|
||||
/**
|
||||
* Two browser contexts, so each player has their own storage, nickname and
|
||||
* socket. One context with two tabs would share a session and prove nothing
|
||||
* about two people playing.
|
||||
*/
|
||||
async function twoPlayers(browser) {
|
||||
const hostContext = await browser.newContext();
|
||||
const guestContext = await browser.newContext();
|
||||
return {
|
||||
host: await hostContext.newPage(),
|
||||
guest: await guestContext.newPage(),
|
||||
async close() {
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates a room as the host and returns its code. */
|
||||
async function createRoom(page, nickname) {
|
||||
await page.goto('/online');
|
||||
await setNickname(page, nickname);
|
||||
await page.getByRole('button', { name: 'Tạo phòng' }).click();
|
||||
|
||||
const code = await page.getByTestId('room-code').textContent();
|
||||
return (code ?? '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
async function joinRoom(page, nickname, code) {
|
||||
await page.goto('/online');
|
||||
await setNickname(page, nickname);
|
||||
await page.getByLabel('Mã phòng').fill(code);
|
||||
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
}
|
||||
|
||||
test.describe('playing a stranger', () => {
|
||||
test('two players join by code and alternate turns', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
expect(code).toMatch(/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/);
|
||||
await expect(host.getByTestId('waiting-status')).toBeVisible();
|
||||
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
|
||||
// Both boards come up, and exactly one player is on turn.
|
||||
await waitForMyTurn(host);
|
||||
await expect(board(guest).turn).toHaveText('Đối thủ đang suy nghĩ…');
|
||||
|
||||
// Each side is shown the other's server-sanitized name.
|
||||
await expect(host.locator('.who', { hasText: 'Lan' })).toBeVisible();
|
||||
await expect(guest.locator('.who', { hasText: 'Minh' })).toBeVisible();
|
||||
|
||||
const used = new Set(await chainWords(host));
|
||||
const hostWord = await playLegalMove(host, used);
|
||||
|
||||
// The move crosses to the other browser, and the turn goes with it.
|
||||
await expect(guest.getByText(hostWord, { exact: true }).first()).toBeVisible();
|
||||
await waitForMyTurn(guest);
|
||||
|
||||
const guestWord = await playLegalMove(guest, used);
|
||||
await expect(host.getByText(guestWord, { exact: true }).first()).toBeVisible();
|
||||
await waitForMyTurn(host);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('an invite link opens straight into the room', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
|
||||
// No code typed, no button pressed beyond opening the link.
|
||||
await guest.goto(`/online?code=${code}`);
|
||||
|
||||
await waitForMyTurn(host);
|
||||
await expect(board(guest).syllable).toBeVisible();
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await waitForMyTurn(host);
|
||||
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
|
||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
await expect(guest.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a rematch restarts in the same room once both agree', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await waitForMyTurn(host);
|
||||
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await expect(guest.getByTestId('rematch-prompt')).toBeVisible();
|
||||
|
||||
// One acceptance is not enough: the other player is still being asked.
|
||||
await host.getByTestId('rematch-accept').click();
|
||||
await expect(guest.getByTestId('rematch-prompt')).toContainText('Đối thủ muốn chơi lại');
|
||||
await expect(host.getByTestId('rematch-prompt')).toContainText('Đang chờ đối thủ');
|
||||
|
||||
await guest.getByTestId('rematch-accept').click();
|
||||
|
||||
// A new game in the same room: the code is unchanged and the board is
|
||||
// back to a single opening word.
|
||||
await waitForMyTurn(host);
|
||||
await expect(host.getByText(code)).toBeVisible();
|
||||
expect(await chainWords(host)).toHaveLength(1);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('an unknown room code is refused in Vietnamese', async ({ page }) => {
|
||||
await page.goto('/online');
|
||||
await setNickname(page, 'Minh');
|
||||
await page.getByLabel('Mã phòng').fill('ZZZZZZ');
|
||||
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
|
||||
await expect(page.getByTestId('join-error')).toHaveText(
|
||||
'Không tìm thấy phòng với mã này.'
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed code is caught before it reaches the server', async ({ page }) => {
|
||||
await page.goto('/online');
|
||||
await page.getByLabel('Mã phòng').fill('ABC');
|
||||
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
|
||||
await expect(page.getByText('Mã phòng gồm sáu ký tự.')).toBeVisible();
|
||||
await expect(page.getByTestId('join-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a full room turns a third player away', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await waitForMyTurn(host);
|
||||
|
||||
const thirdContext = await browser.newContext();
|
||||
const third = await thirdContext.newPage();
|
||||
await joinRoom(third, 'Nam', code);
|
||||
|
||||
await expect(third.getByTestId('join-error')).toHaveText('Phòng đã đủ người.');
|
||||
|
||||
await thirdContext.close();
|
||||
await close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
|
||||
import { cuttableSocket } from './socket-cut.js';
|
||||
|
||||
/**
|
||||
* The reconnect path, driven by cutting one player's socket.
|
||||
*
|
||||
* The cut is abrupt and unannounced, which is the case the grace window exists
|
||||
* for: a player who closes the tab politely is a different, easier story.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Seats two players in a room and returns them with the room code.
|
||||
*
|
||||
* @param {import('@playwright/test').Browser} browser
|
||||
*/
|
||||
async function pvpRoom(browser) {
|
||||
const hostContext = await browser.newContext();
|
||||
const guestContext = await browser.newContext();
|
||||
const host = await hostContext.newPage();
|
||||
const guest = await guestContext.newPage();
|
||||
|
||||
await host.goto('/online');
|
||||
await setNickname(host, 'Minh');
|
||||
await host.getByRole('button', { name: 'Tạo phòng' }).click();
|
||||
const code = ((await host.getByTestId('room-code').textContent()) ?? '').replace(/\s+/g, '');
|
||||
|
||||
await guest.goto('/online');
|
||||
await setNickname(guest, 'Lan');
|
||||
await guest.getByLabel('Mã phòng').fill(code);
|
||||
await guest.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
await waitForMyTurn(host);
|
||||
|
||||
return {
|
||||
host,
|
||||
guest,
|
||||
code,
|
||||
async close() {
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('losing the connection', () => {
|
||||
test('the opponent is told, and a return inside the window resumes the game', async ({
|
||||
browser
|
||||
}) => {
|
||||
const { host, guest, close } = await pvpRoom(browser);
|
||||
|
||||
const used = new Set(await chainWords(host));
|
||||
await playLegalMove(host, used);
|
||||
await waitForMyTurn(guest);
|
||||
|
||||
const hostSyllable = (await board(host).syllable.textContent())?.trim() ?? '';
|
||||
|
||||
// Navigating away takes the socket with it, which is what the server
|
||||
// sees when a player loses their connection. The resume token lives in
|
||||
// this tab's session storage, so coming back can reclaim the seat.
|
||||
await guest.goto('about:blank');
|
||||
|
||||
await expect(host.getByTestId('opponent-status')).toContainText('Đối thủ mất kết nối', {
|
||||
timeout: 20_000
|
||||
});
|
||||
|
||||
// Back inside the grace window.
|
||||
await guest.goto('/online');
|
||||
|
||||
// The seat is restored: the host stops waiting, and the returning player
|
||||
// is looking at the same position rather than the lobby.
|
||||
await expect(host.getByTestId('opponent-status')).toHaveCount(0, { timeout: 20_000 });
|
||||
await expect(board(guest).syllable).toHaveText(hostSyllable, { timeout: 20_000 });
|
||||
await expect(board(guest).turn).toHaveText('Đến lượt bạn');
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('an opponent who never comes back forfeits the game', async ({ browser }) => {
|
||||
const { host, guest, close } = await pvpRoom(browser);
|
||||
|
||||
// Closing the tab: the socket goes and nothing reconnects, so the grace
|
||||
// window has to expire on its own.
|
||||
await guest.close();
|
||||
|
||||
// NOITU_GRACE is 25s on the test server, so this waits it out.
|
||||
await expect(host.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible({
|
||||
timeout: 45_000
|
||||
});
|
||||
await expect(host.getByText('Đối thủ đã rời trận.')).toBeVisible();
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a word cannot be typed into a connection that is down', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const socket = await cuttableSocket(page);
|
||||
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
await socket.cut({ sustained: true });
|
||||
|
||||
await expect(page.getByText('Mất kết nối, đang thử lại…')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Disabled rather than accepting a word that cannot go anywhere and
|
||||
// leaving the player to watch their turn expire.
|
||||
await expect(board(page).input).toBeDisabled();
|
||||
await expect(board(page).submit).toBeDisabled();
|
||||
|
||||
// And it comes back on its own once the connection does.
|
||||
socket.restore();
|
||||
await expect(page.getByText('Đã kết nối')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(board(page).input).toBeEnabled();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* A controllable break in a page's WebSocket.
|
||||
*
|
||||
* Offline emulation is not usable here: Chromium's network conditions do not
|
||||
* apply to a loopback socket, so a test that "went offline" against a local
|
||||
* server kept playing quite happily. Routing the socket cuts it for real.
|
||||
*
|
||||
* The server does not reliably see it, though: closing a routed connection is
|
||||
* observed by the page but does not always reach the far end, so the grace
|
||||
* window may never start. That makes this the right tool for what the client
|
||||
* does about a dead socket, and the wrong one for what the server does about a
|
||||
* missing player. For that, take the page away instead.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export async function cuttableSocket(page) {
|
||||
/** @type {any} */
|
||||
let live = null;
|
||||
/** @type {any} */
|
||||
let upstream = null;
|
||||
let blocked = false;
|
||||
|
||||
await page.routeWebSocket(/\/ws$/, (ws) => {
|
||||
if (blocked) {
|
||||
// Refuse without connecting, so the client keeps retrying and the
|
||||
// outage lasts as long as the test wants it to.
|
||||
ws.close({ code: 1006 });
|
||||
return;
|
||||
}
|
||||
live = ws;
|
||||
// With no message handlers registered, frames are proxied in both
|
||||
// directions, so the game runs against the real server as usual.
|
||||
upstream = ws.connectToServer();
|
||||
});
|
||||
|
||||
return {
|
||||
/** Drops the current connection, optionally refusing reconnects too. */
|
||||
async cut({ sustained = false } = {}) {
|
||||
blocked = sustained;
|
||||
// Both halves. Closing only the page side leaves the server holding
|
||||
// a connection it thinks is fine, so it never starts the grace
|
||||
// window and the opponent is never told.
|
||||
await upstream?.close({ code: 1006 });
|
||||
await live?.close({ code: 1006 });
|
||||
upstream = null;
|
||||
live = null;
|
||||
},
|
||||
/** Lets the next reconnect through. */
|
||||
restore() {
|
||||
blocked = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const PORT = 4173;
|
||||
|
||||
/**
|
||||
* CI installs Playwright's own Chromium. Some machines cannot reach that
|
||||
* download, so a locally installed browser can be used instead by setting
|
||||
* PLAYWRIGHT_CHANNEL=chrome (or msedge). Both drive the same engine.
|
||||
*/
|
||||
const channel = process.env.PLAYWRIGHT_CHANNEL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// A whole game per test, including a bot that deliberately pauses to think.
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['github'], ['list']] : [['list']],
|
||||
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
trace: 'retain-on-failure',
|
||||
...devices['Desktop Chrome'],
|
||||
...(channel ? { channel } : {})
|
||||
},
|
||||
|
||||
projects: [{ name: 'chromium' }],
|
||||
|
||||
// The real binary, serving the real built frontend, against the fixture
|
||||
// dictionary. Nothing here downloads the 179 MB upstream release.
|
||||
webServer: {
|
||||
command: 'go run ./cmd/noitu-server',
|
||||
cwd: '../server',
|
||||
url: `http://localhost:${PORT}/healthz`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
NOITU_ADDR: `:${PORT}`,
|
||||
NOITU_DB_PATH: '../data/fixture.db',
|
||||
NOITU_WEB_DIR: '../web/build',
|
||||
// Long enough that no test races the turn clock. Timeout behaviour
|
||||
// has its own server-side test; here it would only cause flakes.
|
||||
NOITU_TURN_LIMIT: '120s',
|
||||
// Long enough that a client which notices a dead socket on its own
|
||||
// liveness check still has time to come back, and short enough to
|
||||
// wait out in the forfeit test.
|
||||
NOITU_GRACE: '25s'
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user