| 1 | import { expect, test, type ConsoleMessage, type Page, type Request, type Response, type TestInfo } from '@playwright/test' |
| 2 | import { ADMIN_URL, clearUploads, clickAdminMenu, clickIconBtn, loginAdmin, password, uploadName, FRONTEND_URL, username } from './common' |
| 3 | |
| 4 | // this test is separated to run serially, as it will modify folder timestamp for a few seconds, during which other tests may fail |
| 5 | test.describe.configure({ mode: 'serial' }) // to disconnect the upload consistently, i need only 1 upload at a time |
| 6 | |
| 7 | export const fileToUpload = { |
| 8 | name: 'upload-test.bin', |
| 9 | mimeType: 'application/octet-stream', |
| 10 | buffer: Buffer.alloc(100_000), |
| 11 | } |
| 12 | |
| 13 | test('upload1', async ({ page, context, browserName }, testInfo) => { |
| 14 | if (browserName !== 'chromium') return // only chromium has cdpSession |
| 15 | const diagnostics = await startUpload1Diagnostics(page) |
| 16 | try { |
| 17 | await page.goto(FRONTEND_URL) |
| 18 | await page.getByRole('button', { name: 'Login' }).click() |
| 19 | await page.getByRole('textbox', { name: 'Username' }).fill(username) |
| 20 | await page.getByRole('textbox', { name: 'Password' }).fill(password) |
| 21 | await page.getByRole('button', { name: 'Continue' }).click() |
| 22 | await page.locator('div').filter({ hasText: 'Logged in' }).nth(3).click() |
| 23 | |
| 24 | await page.getByRole('link', { name: 'for-admins, Folder' }).click() |
| 25 | await page.getByRole('link', { name: 'upload, Folder' }).click() |
| 26 | |
| 27 | await page.getByRole('button', { name: 'Options' }).click() |
| 28 | const pageAdminPromise = page.waitForEvent('popup') |
| 29 | await page.getByRole('button', { name: 'Admin-panel' }).click() |
| 30 | const pageAdmin = await pageAdminPromise |
| 31 | diagnostics.trackPage(pageAdmin, 'admin') |
| 32 | await pageAdmin.goto(ADMIN_URL + '#/monitoring'); // cross-device way of changing page |
| 33 | await page.locator('div').filter({ hasText: 'xOptionsAdmin-panelSort by:' }).nth(2).click() |
| 34 | await page.getByRole('button', { name: 'Close' }).click() |
| 35 | await page.getByRole('button', { name: 'Upload' }).click() |
| 36 | const fileChooserPromise = page.waitForEvent('filechooser') |
| 37 | await page.getByRole('button', { name: 'Pick files' }).click() |
| 38 | const fileChooser = await fileChooserPromise |
| 39 | await fileChooser.setFiles(fileToUpload) |
| 40 | // can't do without cdp to slow down the upload. I tried using route.continue, but i can't send half-body keeping the full content-length, and i also cannot pass a stream (to throttle) |
| 41 | const cdpSession = await context.newCDPSession(page) |
| 42 | await cdpSession.send('Network.emulateNetworkConditions', NETWORK_PRESETS.Regular2G) |
| 43 | await openUploadRename(page) |
| 44 | const renameDialog = page.locator('.dialog-prompt') |
| 45 | const renameInput = renameDialog.getByRole('textbox') |
| 46 | await expect(renameInput).toHaveValue(fileToUpload.name) // promptDialog initializes the field value in useEffect, so we wait for that init to avoid our fill being overwritten |
| 47 | await renameInput.fill(uploadName) |
| 48 | await renameDialog.getByRole('button', { name: 'Continue' }).click() |
| 49 | await expect(page.getByText(uploadName)).toBeVisible() // rename was effective |
| 50 | // we send the upload, slowly, so that we can interrupt it in the admin-panel to test the upload resume |
| 51 | await page.getByRole('button', { name: 'Send' }).click() |
| 52 | const uploadCells = pageAdmin.locator('.MuiDataGrid-cell') |
| 53 | .filter({ hasText: uploadName }) |
| 54 | .filter({ hasText: '/for-admins/upload' }) |
| 55 | await expect(uploadCells.first()).toBeVisible() |
| 56 | // during upload resume, monitoring can briefly show two rows for the same path |
| 57 | await uploadCells.last().click() |
| 58 | await clickIconBtn('Disconnect', pageAdmin) |
| 59 | await clickIconBtn('Close', pageAdmin) |
| 60 | await pageAdmin.close() |
| 61 | await page.getByText('Copy links').click() |
| 62 | await page.getByText('Operation successful').click() |
| 63 | await page.getByRole('button', { name: 'Close' }).click() |
| 64 | await cdpSession?.send('Network.emulateNetworkConditions', NETWORK_PRESETS.NoThrottle) |
| 65 | clearUploads() |
| 66 | } |
| 67 | catch (err) { |
| 68 | await diagnostics.attach(testInfo) |
| 69 | throw err |
| 70 | } |
| 71 | }) |
| 72 | |
| 73 | async function openUploadRename(page: Page) { |
| 74 | const editButton = page.getByRole('button', { name: 'Edit' }) |
| 75 | if (await editButton.isVisible()) |
| 76 | return editButton.click() |
| 77 | await page.locator('.upload-list').getByRole('button', { name: 'Menu' }).click() |
| 78 | await page.getByRole('link', { name: 'Rename' }).click() |
| 79 | } |
| 80 | |
| 81 | const MAX_DIAGNOSTIC_LINES = 300 |
| 82 | |
| 83 | async function startUpload1Diagnostics(page: Page) { |
| 84 | const lines: string[] = [] |
| 85 | const started = Date.now() |
| 86 | addLine('diagnostics started') |
| 87 | await page.exposeBinding('__upload1Diag', (_source, entry: Record<string, unknown>) => { |
| 88 | addLine(`xhr ${formatEntry(entry)}`) |
| 89 | }) |
| 90 | await page.addInitScript(() => { |
| 91 | const originalOpen = XMLHttpRequest.prototype.open |
| 92 | const originalSend = XMLHttpRequest.prototype.send |
| 93 | XMLHttpRequest.prototype.open = function(method: string, url: string | URL, ...args: any[]) { |
| 94 | ;(this as any).__upload1DiagRequest = { method: String(method), url: String(url) } |
| 95 | return Reflect.apply(originalOpen, this, [method, url, ...args]) |
| 96 | } |
| 97 | XMLHttpRequest.prototype.send = function(...args: any[]) { |
| 98 | const req = (this as any).__upload1DiagRequest |
| 99 | if (req?.method === 'PUT' && req.url.includes('/for-admins/upload/')) { |
| 100 | const log = (event: string, progress?: ProgressEvent) => { |
| 101 | // record XHR state before Playwright closes the browser |
| 102 | Promise.resolve((window as any).__upload1Diag?.({ |
| 103 | event, |
| 104 | readyState: this.readyState, |
| 105 | status: this.status, |
| 106 | url: req.url, |
| 107 | loaded: progress?.loaded, |
| 108 | total: progress?.lengthComputable ? progress.total : undefined, |
| 109 | })).catch(() => {}) |
| 110 | } |
| 111 | for (const event of ['loadstart', 'abort', 'error', 'timeout', 'loadend']) |
| 112 | this.addEventListener(event, log.bind(null, event)) |
| 113 | this.upload.addEventListener('progress', e => log('upload-progress', e)) |
| 114 | this.addEventListener('readystatechange', () => log('readystatechange')) |
| 115 | } |
| 116 | return originalSend.apply(this, args) |
| 117 | } |
| 118 | }) |
| 119 | trackPage(page, 'main') |
| 120 | return { attach, trackPage } |
| 121 | |
| 122 | function trackPage(trackedPage: Page, label: string) { |
| 123 | trackedPage.on('console', msg => addLine(`console:${label} ${formatConsole(msg)}`)) |
| 124 | trackedPage.on('requestfailed', request => addLine(`requestfailed:${label} ${formatRequest(request)} ${request.failure()?.errorText ?? ''}`)) |
| 125 | trackedPage.on('response', response => { |
| 126 | if (isUploadResponse(response)) |
| 127 | addLine(`response:${label} ${response.status()} ${response.request().method()} ${response.url()}`) |
| 128 | }) |
| 129 | } |
| 130 | |
| 131 | async function attach(testInfo: TestInfo) { |
| 132 | await testInfo.attach('upload1-diagnostics', { |
| 133 | body: lines.join('\n') + '\n', |
| 134 | contentType: 'text/plain', |
| 135 | }) |
| 136 | } |
| 137 | |
| 138 | function addLine(text: string) { |
| 139 | const offset = `${Date.now() - started}ms`.padStart(7) |
| 140 | lines.push(`${offset} ${text}`) |
| 141 | if (lines.length > MAX_DIAGNOSTIC_LINES) |
| 142 | lines.splice(0, lines.length - MAX_DIAGNOSTIC_LINES) |
| 143 | } |
| 144 | |
| 145 | function formatConsole(msg: ConsoleMessage) { |
| 146 | return `${msg.type()} ${msg.text()}` |
| 147 | } |
| 148 | |
| 149 | function formatRequest(request: Request) { |
| 150 | return `${request.method()} ${request.url()}` |
| 151 | } |
| 152 | |
| 153 | function isUploadResponse(response: Response) { |
| 154 | const request = response.request() |
| 155 | return request.method() === 'PUT' && response.url().includes('/for-admins/upload/') |
| 156 | } |
| 157 | |
| 158 | function formatEntry(entry: Record<string, unknown>) { |
| 159 | return Object.entries(entry) |
| 160 | .filter(([, value]) => value !== undefined) |
| 161 | .map(([key, value]) => `${key}=${String(value)}`) |
| 162 | .join(' ') |
| 163 | } |
| 164 | |
| 165 | } |
| 166 | |
| 167 | const NETWORK_PRESETS = { |
| 168 | Offline: { |
| 169 | offline: true, |
| 170 | downloadThroughput: 0, |
| 171 | uploadThroughput: 0, |
| 172 | latency: 0, |
| 173 | connectionType: 'none', |
| 174 | }, |
| 175 | NoThrottle: { |
| 176 | offline: false, |
| 177 | downloadThroughput: -1, |
| 178 | uploadThroughput: -1, |
| 179 | latency: 0, |
| 180 | }, |
| 181 | Regular2G: { |
| 182 | offline: false, |
| 183 | downloadThroughput: (250 * 1024) / 8, |
| 184 | uploadThroughput: (120 * 1024) / 8, |
| 185 | latency: 300, |
| 186 | connectionType: 'cellular2g', |
| 187 | }, |
| 188 | } as const |
| 189 | |
| 190 | // some interactions, no screenshots |
| 191 | test('admin2', async ({ page, browserName }) => { |
| 192 | const isPhone = await loginAdmin(page) |
| 193 | |
| 194 | await clickAdminMenu(page, 'Accounts') |
| 195 | await expect(page.getByText('admins', { exact: true })).toBeVisible() |
| 196 | await page.getByRole('button', { name: 'Add' }).click() |
| 197 | await page.getByRole('menuitem', { name: 'user' }).click() |
| 198 | const usernameField = page.getByRole('textbox', { name: 'Username' }) |
| 199 | const passwordField = page.getByRole('textbox', { name: 'Password', exact: true }) |
| 200 | await usernameField.fill('admin2-temp-user') |
| 201 | await passwordField.fill('admin2-temp-pass') |
| 202 | await page.getByRole('textbox', { name: 'Repeat password' }).fill('admin2-temp-pass') |
| 203 | await expect(usernameField).toHaveValue('admin2-temp-user') |
| 204 | const adminAccess = page.getByRole('switch', { name: 'Admin-panel access' }) |
| 205 | await adminAccess.check() |
| 206 | await expect(adminAccess).toBeChecked() |
| 207 | await page.getByRole('textbox', { name: 'Notes' }).fill('admin2 expanded interactions') |
| 208 | if (isPhone) |
| 209 | await clickIconBtn('Close', page) |
| 210 | |
| 211 | await clickAdminMenu(page, 'Options') |
| 212 | await expect(page.getByText('Correctly working on port')).toBeVisible() |
| 213 | await page.getByRole('button', { name: 'Reload' }).click() |
| 214 | await page.getByRole('row', { name: /^Blocked/ }).getByRole('button', { name: /Add/ }).click() |
| 215 | const addDialog = page.getByRole('dialog', { name: /Add/ }) |
| 216 | await addDialog.getByRole('textbox', { name: 'Blocked IP' }).fill('5.6.7.8') |
| 217 | if (!isPhone) { |
| 218 | // This field uses a masked input: selecting from picker is more reliable than typing. |
| 219 | await addDialog.getByRole('button', { name: 'Choose date' }).click() |
| 220 | const picker = page.getByRole('dialog', { name: 'Expire' }) |
| 221 | // desktop calendars render hidden fillers and disabled days as gridcells too, so click a real enabled day button |
| 222 | await picker.locator('button[role="gridcell"]:not([disabled]):not([aria-disabled="true"])').first().click() |
| 223 | await page.keyboard.press('Escape') |
| 224 | } |
| 225 | await addDialog.getByRole('button').last().click() |
| 226 | await expect(addDialog).not.toBeVisible() |
| 227 | await expect(page.getByRole('heading', { name: 'Options', exact: true })).toBeVisible() // still on the same page |
| 228 | await expect(page.getByText('5.6.7.8')).toBeVisible() |
| 229 | await page.getByRole('button', { name: 'Reload' }).click() |
| 230 | await expect(page.getByText('5.6.7.8')).not.toBeVisible() |
| 231 | |
| 232 | await clickAdminMenu(page, 'Logs') |
| 233 | await expect(page.getByRole('tab', { name: 'Served', exact: true })).toBeVisible() |
| 234 | const logTabs = page.getByRole('tab') |
| 235 | await logTabs.nth(1).click() |
| 236 | await logTabs.nth(2).click() |
| 237 | await logTabs.nth(3).click() |
| 238 | await logTabs.nth(4).click() |
| 239 | await logTabs.nth(0).click() |
| 240 | const pauseBtn = page.getByLabel('Pause').getByRole('button') |
| 241 | await expect(pauseBtn).toHaveAttribute('aria-pressed', 'true') |
| 242 | await pauseBtn.click() |
| 243 | await expect(pauseBtn).toHaveAttribute('aria-pressed', 'false') |
| 244 | await pauseBtn.click() |
| 245 | await expect(pauseBtn).toHaveAttribute('aria-pressed', 'true') |
| 246 | const showApisBtn = page.getByRole('button', { name: 'Show APIs' }) |
| 247 | await expect(showApisBtn).toHaveAttribute('aria-pressed', 'true') |
| 248 | await showApisBtn.click() |
| 249 | await expect(showApisBtn).toHaveAttribute('aria-pressed', 'false') |
| 250 | await showApisBtn.click() |
| 251 | await expect(showApisBtn).toHaveAttribute('aria-pressed', 'true') |
| 252 | await clickIconBtn('Options', page) |
| 253 | if (!isPhone) { |
| 254 | const logsDialog = page.getByRole('dialog', { name: /Log options/ }) |
| 255 | const logApisToggle = logsDialog.getByRole('switch', { name: 'Log API requests' }) |
| 256 | await expect(logApisToggle).toBeChecked() |
| 257 | await logApisToggle.click() |
| 258 | await expect(logApisToggle).not.toBeChecked() |
| 259 | await logApisToggle.click() |
| 260 | await expect(logApisToggle).toBeChecked() |
| 261 | } |
| 262 | await clickIconBtn('Close', page) |
| 263 | |
| 264 | await clickAdminMenu(page, 'Plugins') |
| 265 | await expect(page.getByText('antibrute')).toBeVisible() |
| 266 | const pluginTabs = page.getByRole('tab') |
| 267 | await pluginTabs.nth(0).click() |
| 268 | // plugin state is shared by all browser projects, so mutate it from one project to avoid cross-browser races |
| 269 | if (!isPhone && browserName === 'chromium') { |
| 270 | const downloadCounterRow = page.getByRole('row', { name: /download-counter/ }) |
| 271 | const startDownloadCounter = downloadCounterRow.getByRole('button', { name: 'Start download-counter' }) |
| 272 | const stopDownloadCounter = downloadCounterRow.getByRole('button', { name: 'Stop download-counter' }) |
| 273 | // keep the test independent from whatever state a previous run left this plugin in |
| 274 | const wasRunning = await stopDownloadCounter.isVisible() |
| 275 | if (!wasRunning) |
| 276 | await startDownloadCounter.click() |
| 277 | await downloadCounterRow.getByRole('button', { name: 'Options' }).click() |
| 278 | const whereField = page.getByRole('combobox', { name: 'Where to display counter' }) |
| 279 | await whereField.click() |
| 280 | await page.getByRole('option', { name: 'list', exact: true }).click() |
| 281 | const pluginOptionsDialog = page.getByRole('dialog') |
| 282 | const pluginSaveBtn = pluginOptionsDialog.locator('button:has-text("Save")').first() |
| 283 | await expect(pluginSaveBtn).toBeEnabled() |
| 284 | await clickIconBtn('Close', page) |
| 285 | if (!wasRunning) |
| 286 | await stopDownloadCounter.click() |
| 287 | } |
| 288 | await pluginTabs.nth(1).click() // get more |
| 289 | await page.getByRole('textbox', { name: 'Search text' }).fill('download') |
| 290 | |
| 291 | await clickAdminMenu(page, 'Custom HTML') |
| 292 | const sectionStyle = page.getByRole('combobox', { name: 'Section Style' }) |
| 293 | await expect(sectionStyle).toBeVisible() |
| 294 | await sectionStyle.click() |
| 295 | await page.getByRole('option').nth(1).click() |
| 296 | |
| 297 | await clickAdminMenu(page, 'Internet') |
| 298 | await expect(page.getByText('Server')).toBeVisible() |
| 299 | |
| 300 | await clickAdminMenu(page, 'Logout') |
| 301 | }) |