diff --git a/web/e2e/bot-game.spec.js b/web/e2e/bot-game.spec.js new file mode 100644 index 0000000..36d0660 --- /dev/null +++ b/web/e2e/bot-game.spec.js @@ -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); + }); +}); diff --git a/web/e2e/fixture-dictionary.js b/web/e2e/fixture-dictionary.js new file mode 100644 index 0000000..c2c433b --- /dev/null +++ b/web/e2e/fixture-dictionary.js @@ -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} */ +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} 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 }; diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js new file mode 100644 index 0000000..87db4f2 --- /dev/null +++ b/web/e2e/helpers.js @@ -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} used + * @returns {Promise} + */ +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} + */ +export async function chainWords(page) { + return page.locator('ol li .word').allTextContents(); +} diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js new file mode 100644 index 0000000..9a42569 --- /dev/null +++ b/web/e2e/pvp-game.spec.js @@ -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(); + }); +}); diff --git a/web/e2e/reconnect.spec.js b/web/e2e/reconnect.spec.js new file mode 100644 index 0000000..49145a5 --- /dev/null +++ b/web/e2e/reconnect.spec.js @@ -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(); + }); +}); diff --git a/web/e2e/socket-cut.js b/web/e2e/socket-cut.js new file mode 100644 index 0000000..d14f748 --- /dev/null +++ b/web/e2e/socket-cut.js @@ -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; + } + }; +} diff --git a/web/playwright.config.js b/web/playwright.config.js new file mode 100644 index 0000000..fd04618 --- /dev/null +++ b/web/playwright.config.js @@ -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' + } + } +});