|
| 1 | +import type { Page, Locator } from '@playwright/test'; |
| 2 | + |
| 3 | +/** Detected log level — drives the per-line badge color and the Levels filter. */ |
| 4 | +export type LogLevelLabel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Page object for the `/logs` route added in CoderCoco/game-server-deploy#63. |
| 8 | + * Wraps the LIVE/PAUSED pill, the searchable game combobox, the in-stream |
| 9 | + * search input, the Levels multi-select, the autoscroll toggle, the |
| 10 | + * Pause/Resume button, the log box, and the footer line-count summary so |
| 11 | + * spec files read as test logic rather than locator soup. |
| 12 | + */ |
| 13 | +export class LogsPage { |
| 14 | + constructor(public readonly page: Page) {} |
| 15 | + |
| 16 | + /** Navigate to `/logs` directly. */ |
| 17 | + async goto(): Promise<void> { |
| 18 | + await this.page.goto('/logs'); |
| 19 | + } |
| 20 | + |
| 21 | + // ── Header ─────────────────────────────────────────────────────────── |
| 22 | + |
| 23 | + /** "Server Logs" heading — used as a "the page mounted" smoke check. */ |
| 24 | + heading(): Locator { |
| 25 | + return this.page.getByRole('heading', { name: 'Server Logs' }); |
| 26 | + } |
| 27 | + |
| 28 | + /** |
| 29 | + * The LIVE/PAUSED status pill. Exact-match prevents the badge from |
| 30 | + * substring-matching incidental words ("Lively", "Alive") inside log |
| 31 | + * lines. |
| 32 | + */ |
| 33 | + liveBadge(): Locator { |
| 34 | + return this.page.getByText('Live', { exact: true }); |
| 35 | + } |
| 36 | + |
| 37 | + /** Counterpart to `liveBadge()` — visible while the stream is paused. */ |
| 38 | + pausedBadge(): Locator { |
| 39 | + return this.page.getByText('Paused', { exact: true }); |
| 40 | + } |
| 41 | + |
| 42 | + // ── Toolbar ────────────────────────────────────────────────────────── |
| 43 | + |
| 44 | + /** |
| 45 | + * Game combobox trigger. The `aria-label` always starts with |
| 46 | + * `"Game selector"` so the regex matches regardless of which game is |
| 47 | + * currently selected. |
| 48 | + */ |
| 49 | + gameComboboxTrigger(): Locator { |
| 50 | + return this.page.getByRole('button', { name: /^Game selector/ }); |
| 51 | + } |
| 52 | + |
| 53 | + /** Search input rendered inside the combobox popover after it opens. */ |
| 54 | + gameSearchInput(): Locator { |
| 55 | + return this.page.getByPlaceholder('Search games…'); |
| 56 | + } |
| 57 | + |
| 58 | + /** Filtered game item inside the open popover, by game name. */ |
| 59 | + gameOption(name: string): Locator { |
| 60 | + return this.page.getByRole('button', { name, exact: true }); |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * Open the combobox, type into the search filter, and click the |
| 65 | + * matching game option. The trigger collapses on selection. |
| 66 | + */ |
| 67 | + async selectGame(name: string): Promise<void> { |
| 68 | + await this.gameComboboxTrigger().click(); |
| 69 | + await this.gameSearchInput().fill(name); |
| 70 | + await this.gameOption(name).click(); |
| 71 | + } |
| 72 | + |
| 73 | + /** In-stream search input that highlights matches in the visible buffer. */ |
| 74 | + searchInput(): Locator { |
| 75 | + return this.page.getByPlaceholder('Search visible buffer…'); |
| 76 | + } |
| 77 | + |
| 78 | + /** Type into the in-stream search input and let React re-render highlights. */ |
| 79 | + async search(query: string): Promise<void> { |
| 80 | + await this.searchInput().fill(query); |
| 81 | + } |
| 82 | + |
| 83 | + /** |
| 84 | + * Levels multi-select trigger. The button label reads `Levels (N/4)`, |
| 85 | + * so a `/Levels/` regex matches no matter how many levels are currently |
| 86 | + * shown — narrow with `levelsTriggerWithCount` for an exact count. |
| 87 | + */ |
| 88 | + levelsTrigger(): Locator { |
| 89 | + return this.page.getByRole('button', { name: /Levels/ }); |
| 90 | + } |
| 91 | + |
| 92 | + /** Levels trigger asserted to display a specific visible-count (e.g. `3/4`). */ |
| 93 | + levelsTriggerWithCount(visible: number, total = 4): Locator { |
| 94 | + // The button's accessible name is the visible text "Levels (V/T)". Exact |
| 95 | + // match on a literal string avoids constructing a dynamic regex (and the |
| 96 | + // CodeQL "incomplete string escaping" alert that comes with it). |
| 97 | + return this.page.getByRole('button', { name: `Levels (${visible}/${total})`, exact: true }); |
| 98 | + } |
| 99 | + |
| 100 | + /** Checkbox item inside the open Levels menu, by level label. */ |
| 101 | + levelMenuItem(level: LogLevelLabel): Locator { |
| 102 | + return this.page.getByRole('menuitemcheckbox', { name: level }); |
| 103 | + } |
| 104 | + |
| 105 | + /** |
| 106 | + * Open the Levels menu, toggle a level off (or on), and dismiss the menu |
| 107 | + * with Escape so subsequent assertions aren't obscured by the popover. |
| 108 | + * The menu stays open by design (`onSelect` preventDefault) so we close |
| 109 | + * it explicitly here. |
| 110 | + */ |
| 111 | + async toggleLevel(level: LogLevelLabel): Promise<void> { |
| 112 | + await this.levelsTrigger().click(); |
| 113 | + await this.levelMenuItem(level).click(); |
| 114 | + await this.page.keyboard.press('Escape'); |
| 115 | + } |
| 116 | + |
| 117 | + /** Autoscroll checkbox — wrapped in a `<label>` with text "Autoscroll". */ |
| 118 | + autoscrollCheckbox(): Locator { |
| 119 | + return this.page.getByLabel('Autoscroll'); |
| 120 | + } |
| 121 | + |
| 122 | + /** Pause/Resume button. The accessible name flips with the state. */ |
| 123 | + pauseButton(): Locator { |
| 124 | + return this.page.getByRole('button', { name: 'Pause' }); |
| 125 | + } |
| 126 | + |
| 127 | + /** Counterpart to `pauseButton()` — visible while the stream is paused. */ |
| 128 | + resumeButton(): Locator { |
| 129 | + return this.page.getByRole('button', { name: 'Resume' }); |
| 130 | + } |
| 131 | + |
| 132 | + // ── Log stream ─────────────────────────────────────────────────────── |
| 133 | + |
| 134 | + /** |
| 135 | + * A `<mark>` highlight rendered by the in-stream search. Without a |
| 136 | + * search query active the page contains zero `<mark>` elements, so this |
| 137 | + * is a stable signal for "search-highlighting is working". |
| 138 | + */ |
| 139 | + highlightMarks(): Locator { |
| 140 | + return this.page.locator('mark'); |
| 141 | + } |
| 142 | + |
| 143 | + /** A specific search highlight by exact matched text. */ |
| 144 | + highlightMark(text: string): Locator { |
| 145 | + return this.page.locator('mark', { hasText: text }); |
| 146 | + } |
| 147 | + |
| 148 | + /** |
| 149 | + * The first level badge of a given level inside the log box. Each |
| 150 | + * matching line renders one badge; this picks the first occurrence |
| 151 | + * which is enough to assert "this level was detected at all". |
| 152 | + */ |
| 153 | + levelBadge(level: LogLevelLabel): Locator { |
| 154 | + return this.page.getByText(level, { exact: true }).first(); |
| 155 | + } |
| 156 | + |
| 157 | + // ── Footer ─────────────────────────────────────────────────────────── |
| 158 | + |
| 159 | + /** |
| 160 | + * Footer summary line — `<N> lines · oldest <age>` plus optional |
| 161 | + * "<K> levels hidden" / "buffered N" suffixes. `count` anchors the |
| 162 | + * regex to the start so unrelated `5` substrings elsewhere don't |
| 163 | + * match. |
| 164 | + */ |
| 165 | + footerLineCount(count: number): Locator { |
| 166 | + return this.page.getByText(new RegExp(`^${count} lines? · oldest `)); |
| 167 | + } |
| 168 | +} |
0 commit comments