@samitouri / QOSami-HFS / commits / 8a789876

admin: nicer save button on options and shared-files

Massimo Melina committed Feb 22, 2026 at 00:58 UTC 8a7898765af6fd29c53ec41e8b1be3f6cf804fd5
8 files changed +184 -184
admin/src/OptionsPage.ts
+4 -1
@@ -13,6 +13,7 @@ import {
13 } from './misc'
14 import {
15 iconTooltip, InLink, LinkBtn, propsForModifiedValues, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported,
16 + execDoneMessage,
17 } from './mui'
18 import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
19 import { ArrayField } from './ArrayField'
@@ -45,6 +46,7 @@ export default function OptionsPage() {
46 useEffect(() => void reloadStatus(), [data]) //eslint-disable-line
47 useEffect(() => () => exposedReloadStatus = undefined, []) // clear on unmount
48 const sm = useBreakpoint('sm')
49 + const saveBtnRef = useRef<HTMLButtonElement>(null)
50
51 const admins = useApiEx('get_admins').data?.list
52
@@ -87,6 +89,7 @@ export default function OptionsPage() {
89 stickyBar: true,
90 onError: alertDialog,
91 save: {
92 + ref: saveBtnRef,
93 onClick: save,
94 ...propsForModifiedValues( Object.keys(changes).length>0),
95 },
@@ -312,7 +315,7 @@ export default function OptionsPage() {
315 setTimeout(reloadStatus, portChange || certChange ? 1000 : 0) // give some time to apply news
316 Object.assign(loaded!, changes) // since changes are recalculated subscribing state.config, but it depends on 'loaded' to (which cannot be subscribed), be sure to update loaded first
317 recalculateChanges()
315 - toast("Changes applied", 'success')
318 + execDoneMessage(false, saveBtnRef.current)
319 }
320 }
321
admin/src/VfsMenuBar.ts
+11 -11
@@ -32,6 +32,7 @@ export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusA
32 title: "Save",
33 disabled: !vfsModified && "No changes to save",
34 modified: vfsModified,
35 + doneAnimation: true,
36 onClick: saveVfs
37 }),
38 h(Btn, {
@@ -106,15 +107,14 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined })
107 })
108 }
109
109 -function saveVfs() {
110 - apiCall('set_vfs', { uri: '/', props: recur() })
111 - .then(() => {
112 - state.vfsModified = false
113 - })
114 - //.then(() => toast("Changes saved"))
115 - function recur(n=state.vfs) {
116 - const ret = _.pick(n, VFS_STORED_KEYS)
117 - ret.children = n?.children?.map(recur) as any
118 - return ret
119 - }
110 +async function saveVfs() {
111 + await apiCall('set_vfs', {
112 + uri: '/',
113 + props: (function recur(n=state.vfs) {
114 + const ret = _.pick(n, VFS_STORED_KEYS)
115 + ret.children = n?.children?.map(recur) as any
116 + return ret
117 + })()
118 + })
119 + state.vfsModified = false
120 }
admin/src/mui.ts
+19 -9
@@ -153,7 +153,7 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link,
153 if (link)
154 onClick = () => window.open(link)
155 const showLabel = useBreakpoint(_.isString(labelIf) ? labelIf : 'xs') && (_.isBoolean(labelIf) ? labelIf : true)
156 - if (!showLabel)
156 + if (!showLabel && children)
157 title = children
158 const ref = useRefPass<HTMLButtonElement>(forwarded)
159 const common = _.merge(propsForModifiedValues(modified), {
@@ -164,11 +164,15 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link,
164 if (loadingState) return
165 if (confirm && !await confirmDialog(confirm === true ? "Are you sure?" : confirm)) return
166 const ret = onClick?.apply(this, args as any)
167 - if (ret && ret instanceof Promise) {
167 + if (ret instanceof Promise) {
168 setLoadingState(true)
169 - ret.then(x => x !== false && execDoneMessage(doneMessage, doneAnimation && ref.current), alertDialog)
170 - .finally(()=> setLoadingState(false))
169 + ret.finally(()=> setLoadingState(false))
170 }
171 + try {
172 + if (await ret !== false)
173 + execDoneMessage(doneMessage, doneAnimation && ref.current)
174 + }
175 + catch(e: any) { alertDialog(e) }
176 },
177 } as const, rest)
178 const iconElement = isValidElement(icon) ? icon : (icon && h(icon))
@@ -181,7 +185,11 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link,
185 : h(CircularProgress, { size: '1rem', value: progress*100, variant: 'determinate' }),
186 children: showLabel && children,
187 } as const, common, (!showLabel || !children) && { sx: { minWidth: 'auto', px: 1, py: '7px', '& span': { mx:0 }, } }))
184 - : h(IconButton, _.merge(common, { sx: { height: 'fit-content' }, TouchRippleProps: { 'aria-hidden': true } }),
188 + : h(IconButton, _.merge(common, {
189 + sx: { height: 'fit-content' }, TouchRippleProps: { 'aria-hidden': true },
190 + // we need a direct accessible name on the actual clickable element for testing
191 + 'aria-label': !children || !showLabel ? rest['aria-label'] ?? (_.isString(title) ? title : undefined) : rest['aria-label'],
192 + }),
193 (progress || loadingState) && progress !== false // false is also useful to inhibit behavior with loading
194 && h(CircularProgress, {
195 ...(typeof progress === 'number' ? { value: progress*100, variant: 'determinate' } : null),
@@ -190,16 +198,18 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link,
198 iconElement,
199 )
200
193 - const aria = rest['aria-label'] ?? with_(_.isString(title) && title, x => x ? `${children || ''} (${x})` : undefined)
201 + const aria = rest['aria-label']
202 + ?? with_(_.isString(title) && title, x =>
203 + x ? `${prefix('', _.isString(children) && children, ' – ')}${x}` : undefined)
204 if (title) {
195 - if (disabled) // having this span-wrapper conditioned by if(disabled) is causing a (harmless?) warning by mui-popper if the element becomes disabled after you click (file cut button does), but otherwise we have a bigger problem with a11y, with this being seen as a button
196 - ret = h('span', { role: 'button', 'aria-label': aria, 'aria-disabled': disabled }, ret)
205 + // Keep a stable tooltip anchor while `disabled` toggles, otherwise MUI may keep a stale anchorEl and warn.pc
206 + ret = h('span', disabled ? { role: 'button', 'aria-label': aria, 'aria-disabled': true } : undefined, ret)
207 ret = hTooltip(title, aria, ret, tooltipProps)
208 }
209 return ret
210 })
211
202 -function execDoneMessage(msg: boolean | string | undefined, el?: HTMLElement | null | false) {
212 +export function execDoneMessage(msg: boolean | string | undefined, el?: HTMLElement | null | false) {
213 if (el)
214 restartAnimation(el, 'success .5s')
215 if (msg)
e2e/admin-vfs.spec.ts
+10 -10
@@ -1,5 +1,5 @@
1 import { expect, Page, test } from '@playwright/test'
2 -import { clickAdminMenu, URL, username, password } from './common'
2 +import { clickAdminMenu, URL, username, password, clickIconBtn } from './common'
3
4 async function selectVfsNode(page: Page, name: string, expectedId: string) {
5 await page.getByRole('treeitem', { name, exact: true }).click()
@@ -8,7 +8,7 @@ async function selectVfsNode(page: Page, name: string, expectedId: string) {
8 }
9
10 async function pasteMovingNode(page: Page, movingName: string) {
11 - await page.getByRole('button', { name: new RegExp(movingName) }).click()
11 + await clickIconBtn(new RegExp(movingName), page)
12 }
13
14 async function expandVfsNode(page: Page, nodeId: string) {
@@ -31,8 +31,8 @@ test('move via cut/paste keeps node visible', async ({ page }) => {
31 await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
32
33 await selectVfsNode(page, 'zipNoList', '/zipNoList/')
34 - await page.getByRole('button', { name: 'Cut' }).click()
35 - await page.getByRole('button', { name: 'Close' }).click()
34 + await clickIconBtn('Cut', page)
35 + await clickIconBtn('Close', page)
36 await selectVfsNode(page, 'f1', '/f1/')
37 await pasteMovingNode(page, 'zipNoList')
38
@@ -69,8 +69,8 @@ test('move to nested destination expands ancestors', async ({ page }) => {
69 await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
70
71 await selectVfsNode(page, 'zipNoList', '/zipNoList/')
72 - await page.getByRole('button', { name: 'Cut' }).click()
73 - await page.getByRole('button', { name: 'Close' }).click()
72 + await clickIconBtn('Cut', page)
73 + await clickIconBtn('Close', page)
74 await expandVfsNode(page, '/protectFromAbove/')
75 await selectVfsNode(page, 'child', '/protectFromAbove/child/')
76 await pasteMovingNode(page, 'zipNoList')
@@ -111,8 +111,8 @@ test('move into empty folder keeps node visible', async ({ page }) => {
111 await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
112
113 await selectVfsNode(page, 'zipNoList', '/zipNoList/')
114 - await page.getByRole('button', { name: 'Cut' }).click()
115 - await page.getByRole('button', { name: 'Close' }).click()
114 + await clickIconBtn('Cut', page)
115 + await clickIconBtn('Close', page)
116 await selectVfsNode(page, 'for-disabled', '/for-disabled/')
117 await pasteMovingNode(page, 'zipNoList')
118
@@ -150,7 +150,7 @@ test('delete virtual folder updates tree and marks modified', async ({ page }) =
150
151 const folderName = 'for-disabled'
152 await selectVfsNode(page, folderName, '/for-disabled/')
153 - await page.getByRole('button', { name: 'Delete' }).first().click()
153 + await clickIconBtn('Delete', page)
154 const confirm = page.locator('.dialog-confirm')
155 await expect(confirm).toBeVisible()
156 await confirm.locator('a').first().click()
@@ -180,7 +180,7 @@ test('undo toggles with single-level redo behavior', async ({ page }) => {
180 await expect(undoButton).toBeDisabled()
181
182 await selectVfsNode(page, folderName, '/for-disabled/')
183 - await page.getByRole('button', { name: 'Delete' }).first().click()
183 + await clickIconBtn('Delete', page)
184 const confirm = page.locator('.dialog-confirm')
185 await expect(confirm).toBeVisible()
186 await confirm.locator('a').first().click()
e2e/common.ts
+16 -1
@@ -25,7 +25,7 @@ export function forwardConsole(page: Page) {
25 }
26
27 export async function clickAdminMenu(page: Page, sectionName: string | RegExp) {
28 - const isPhone = await page.evaluate(() => window.matchMedia('(max-width: 600px)').matches)
28 + const isPhone = (page as any).isPhone ??= await page.evaluate(() => window.matchMedia('(max-width: 600px)').matches)
29 if (isPhone) {
30 // On phones, admin navigation links are rendered inside a drawer that must be opened first.
31 await page.getByRole('button', { name: 'menu' }).nth(0).click()
@@ -34,3 +34,18 @@ export async function clickAdminMenu(page: Page, sectionName: string | RegExp) {
34 // The admin page content updates asynchronously after route changes; this avoids transient flakiness across tests.
35 await page.waitForTimeout(100)
36 }
37 +
38 +// only for admin-panel
39 +export function clickIconBtn(title: string | RegExp, page: Page) {
40 + return page.getByLabel(title).getByRole('button').click()
41 +}
42 +
43 +export async function loginAdmin(page: Page) {
44 + await page.goto(URL + '~/admin/')
45 + await page.getByRole('textbox', { name: 'Username' }).fill(username)
46 + await page.getByRole('textbox', { name: 'Password' }).fill(password)
47 + await page.getByRole('textbox', { name: 'Password' }).press('Enter')
48 + const isPhone = await page.evaluate(() => window.matchMedia("(max-width: 600px)").matches)
49 + ;(page as any).isPhone = isPhone
50 + return isPhone
51 +}
e2e/frontend.spec.ts
+15 -148
@@ -1,7 +1,9 @@
1 import { test, expect, Page } from '@playwright/test'
2 import fs from 'fs'
3 import { wait } from '../src/cross'
4 -import { clickAdminMenu, forwardConsole, password, resetTimestamp, URL, username } from './common'
4 +import {
5 + clickAdminMenu, clickIconBtn, forwardConsole, loginAdmin, password, resetTimestamp, URL, username
6 +} from './common'
7
8 // a generic test touch several parts
9 test('around1', async ({ page }) => {
@@ -230,7 +232,8 @@ test('admin1', async ({ page }) => {
232 await screenshot(page)
233 await page.getByText('rejetto(admins,').click()
234 await screenshot(page)
233 - await closeAdminPhoneDialog(page, isPhone)
235 + if (isPhone)
236 + await clickIconBtn('Close', page)
237 await clickAdminMenu(page, 'Options')
238 await expect(page.getByText('Correctly working on port')).toBeVisible() // wait for data
239 if (!isPhone)
@@ -241,17 +244,13 @@ test('admin1', async ({ page }) => {
244 await clickAdminMenu(page, 'Logs')
245 await dataTableLoading()
246 await screenshot(page)
244 - await page.getByRole('tab').nth(2).click()
245 - await page.getByRole('tab').nth(3).click()
246 - await page.getByRole('tab').nth(4).click()
247 - await page.getByRole('button', { name: '(Options)' }).click()
247 + await clickIconBtn('Options', page)
248 await page.locator('div').filter({ hasText: 'ServedRequests are logged here. Empty to disable it.Not servedWrite errors in a different file. Empty to use same file.' }).nth(3).click()
249 - await page.getByRole('button', { name: 'Close' }).click()
250 - await expect(page.getByText('LogsServedNot')).toBeVisible()
249 + await clickIconBtn('Close', page)
250 await clickAdminMenu(page, 'Language')
251 await dataTableLoading()
252 if (!isPhone)
254 - await expect(page.getByText('author', { exact: true })).toBeVisible() // wait for layout to be stable
253 + await expect(page.getByText('author', { exact: true })).toBeVisible() // wait for the layout to be stable
254 await screenshot(page, '.MuiDataGrid-root')
255 await clickAdminMenu(page, 'Plugins')
256 await expect(page.getByText('antibrute')).toBeVisible() // wait for data
@@ -270,138 +269,6 @@ test('admin1', async ({ page }) => {
269 await screenshot(page)
270 })
271
273 -// some interactions, no screenshots
274 -test('admin2', async ({ page }) => {
275 - const isPhone = await loginAdmin(page)
276 -
277 - await clickAdminMenu(page, 'Accounts')
278 - await expect(page.getByText('admins', { exact: true })).toBeVisible()
279 - await page.getByRole('button', { name: 'Add' }).click()
280 - await page.getByRole('menuitem', { name: 'user' }).click()
281 - const usernameField = page.getByRole('textbox', { name: 'Username' })
282 - const passwordField = page.getByRole('textbox', { name: 'Password', exact: true })
283 - await usernameField.fill('admin2-temp-user')
284 - await passwordField.fill('admin2-temp-pass')
285 - await page.getByRole('textbox', { name: 'Repeat password' }).fill('admin2-temp-pass')
286 - await expect(usernameField).toHaveValue('admin2-temp-user')
287 - const adminAccess = page.getByRole('checkbox', { name: 'Admin-panel access' })
288 - await adminAccess.check()
289 - await expect(adminAccess).toBeChecked()
290 - await page.getByRole('textbox', { name: 'Notes' }).fill('admin2 expanded interactions')
291 - await closeAdminPhoneDialog(page, isPhone)
292 -
293 - await clickAdminMenu(page, 'Options')
294 - await expect(page.getByText('Correctly working on port')).toBeVisible()
295 - await page.getByRole('button', { name: 'Reload' }).click()
296 - const blockTable = page.getByRole('grid').filter({ has: page.getByText('Blocked IP', { exact: true }) }).first()
297 - await blockTable.getByRole('button', { name: 'Add' }).click()
298 - const addDialog = page.getByRole('dialog').filter({ hasText: 'Add' })
299 - await addDialog.getByRole('textbox', { name: 'Blocked IP' }).fill('5.6.7.8')
300 - if (!isPhone) {
301 - // This field uses a masked input: selecting from picker is more reliable than typing.
302 - await addDialog.getByRole('button', { name: 'Choose date' }).click()
303 - const picker = page.locator('.MuiPickersPopper-root[role="dialog"]')
304 - await picker.locator('button.MuiPickersDay-root:not([disabled])').first().click()
305 - // Close the popper so it doesn't intercept clicks on the Add dialog buttons.
306 - await page.keyboard.press('Escape')
307 - await expect(addDialog.getByRole('textbox', { name: 'Expire' })).not.toHaveValue('MM/DD/YYYY hh:mm aa')
308 - }
309 - await addDialog.getByRole('button').last().click()
310 - await expect(addDialog).not.toBeVisible()
311 - await expect(page.getByRole('heading', { name: 'Options', exact: true })).toBeVisible() // still on the same page
312 - await expect(blockTable.getByText('5.6.7.8')).toBeVisible()
313 - await page.getByRole('button', { name: 'Reload' }).click()
314 - await expect(blockTable.getByText('5.6.7.8')).not.toBeVisible()
315 -
316 - await clickAdminMenu(page, 'Logs')
317 - await expect(page.getByRole('tab', { name: 'Served', exact: true })).toBeVisible()
318 - const logTabs = page.getByRole('tab')
319 - await logTabs.nth(1).click()
320 - await logTabs.nth(2).click()
321 - await logTabs.nth(3).click()
322 - await logTabs.nth(4).click()
323 - const pauseBtn = page.getByRole('button', { name: 'Pause' })
324 - await expect(pauseBtn).toHaveAttribute('aria-pressed', 'true')
325 - await pauseBtn.click()
326 - await expect(pauseBtn).toHaveAttribute('aria-pressed', 'false')
327 - await pauseBtn.click()
328 - await expect(pauseBtn).toHaveAttribute('aria-pressed', 'true')
329 - const showApisBtn = page.getByRole('button', { name: 'Show APIs' })
330 - await expect(showApisBtn).toHaveAttribute('aria-pressed', 'true')
331 - await showApisBtn.click()
332 - await expect(showApisBtn).toHaveAttribute('aria-pressed', 'false')
333 - await showApisBtn.click()
334 - await expect(showApisBtn).toHaveAttribute('aria-pressed', 'true')
335 - await page.getByRole('button', { name: '(Options)' }).click()
336 - if (!isPhone) {
337 - const logsDialog = page.getByRole('dialog', { name: /Log options/ })
338 - const logApisToggle = logsDialog.getByRole('checkbox', { name: 'Log API requests' })
339 - await expect(logApisToggle).toBeChecked()
340 - await logApisToggle.click()
341 - await expect(logApisToggle).not.toBeChecked()
342 - await logApisToggle.click()
343 - await expect(logApisToggle).toBeChecked()
344 - }
345 - await page.getByRole('button', { name: 'Close' }).click()
346 -
347 - await clickAdminMenu(page, 'Plugins')
348 - await expect(page.getByText('antibrute')).toBeVisible()
349 - // Tab labels vary by layout/version ("Search", "Get more"), but the order is stable.
350 - const pluginTabs = page.getByRole('tab')
351 - await pluginTabs.nth(1).click()
352 - await page.getByRole('textbox', { name: 'Search text' }).fill('download')
353 - await pluginTabs.nth(2).click()
354 - await pluginTabs.nth(0).click()
355 - if (!isPhone) {
356 - // Keep this non-invasive: if all plugins are stopped by baseline config, skip plugin-options editing.
357 - const pluginOptionsBtn = page.getByRole('button', { name: '(Options)' }).first()
358 - if (await pluginOptionsBtn.isVisible()) {
359 - await pluginOptionsBtn.click()
360 - const whereField = page.getByRole('combobox', { name: 'Where to display counter' })
361 - await whereField.click()
362 - await page.getByRole('option', { name: 'list', exact: true }).click()
363 - const pluginOptionsDialog = page.getByRole('dialog')
364 - // Same reason as log options: accessible name may include shortcut hints.
365 - const pluginSaveBtn = pluginOptionsDialog.locator('button:has-text("Save")').first()
366 - await expect(pluginSaveBtn).toBeEnabled()
367 - await page.getByRole('button', { name: 'Close' }).click()
368 - }
369 - }
370 -
371 - await clickAdminMenu(page, 'Custom HTML')
372 - const sectionStyle = page.getByRole('combobox', { name: 'Section Style' })
373 - await expect(sectionStyle).toBeVisible()
374 - await sectionStyle.click()
375 - await page.getByRole('option').nth(1).click()
376 -
377 - await clickAdminMenu(page, 'Internet')
378 - await expect(page.getByText('Server')).toBeVisible()
379 -
380 - await clickAdminMenu(page, 'Logout')
381 - await screenshot(page)
382 -})
383 -
384 -async function loginAdmin(page: Page) {
385 - await page.goto(URL + '~/admin/')
386 - await page.getByRole('textbox', { name: 'Username' }).fill(username)
387 - await page.getByRole('textbox', { name: 'Password' }).fill(password)
388 - await page.getByRole('textbox', { name: 'Password' }).press('Enter')
389 - const isPhone = await page.evaluate(() => window.matchMedia("(max-width: 600px)").matches)
390 - ;(page as AdminPage).isPhone = isPhone
391 - return isPhone
392 -}
393 -
394 -async function closeAdminPhoneDialog(page: Page, isPhone: boolean) {
395 - // On phones, detail pages are shown in dialogs and block the menu behind them.
396 - if (isPhone)
397 - // Mobile close button is icon-only, so we target the first button in the dialog header.
398 - await page.getByRole('dialog').getByRole('button').first().click()
399 -}
400 -
401 -type AdminPage = Page & {
402 - isPhone?: boolean
403 -}
404 -
272 async function screenshot(page: Page, selectorForMask = '') {
273 if (selectorForMask)
274 selectorForMask = ',' + selectorForMask
@@ -443,18 +310,18 @@ test('anew', async ({ page, browserName }) => {
310 await adminPage.locator('.MuiDialog-container').press('Escape')
311 await adminPage.locator('#vfs').click()
312 await adminPage.getByText('folder1', { exact: true }).click()
446 - await adminPage.getByRole('button', { name: 'Cut' }).click()
313 + await clickIconBtn('Cut', adminPage)
314 await adminPage.locator('div').filter({ hasText: 'InfoNow that this is marked' }).nth(1).click()
448 - await adminPage.getByRole('button', { name: 'Close' }).click()
315 + await clickIconBtn('Close', adminPage)
316 await adminPage.getByRole('treeitem', { name: 'Home folder', exact: true })
317 .getByText('Home folder', { exact: true }).click()
451 - await adminPage.getByRole('button', { name: '(/work2/folder1/)' }).click() // paste button
318 + await clickIconBtn('/work2/folder1/', adminPage) // paste button
319 await adminPage.getByText('data.kv').click()
453 - await adminPage.getByRole('button', { name: 'Cut' }).click()
454 - await adminPage.getByRole('button', { name: 'Close' }).click()
320 + await clickIconBtn('Cut', adminPage)
321 + await clickIconBtn('Close', adminPage)
322 await adminPage.getByText('folder1').click()
456 - await adminPage.getByRole('button', { name: '(/data.kv)' }).click() // paste
457 - await adminPage.getByRole('button', { name: 'Save' }).click()
323 + await clickIconBtn('/data.kv', adminPage) // paste
324 + await clickIconBtn('Save', adminPage)
325 await page.getByRole('button', { name: 'Close' }).click()
326 await page.getByRole('link', { name: 'home' }).click()
327 await page.getByRole('link', { name: 'Reload' }).click()
e2e/serial.spec.ts
+108 -3
@@ -1,5 +1,5 @@
1 import { expect, test } from '@playwright/test'
2 -import { clearUploads, password, uploadName, URL, username } from './common'
2 +import { clearUploads, clickAdminMenu, clickIconBtn, loginAdmin, password, uploadName, 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
@@ -49,8 +49,8 @@ test('upload1', async ({ page, context, browserName }) => {
49 await expect(uploadCells.first()).toBeVisible()
50 // during upload resume, monitoring can briefly show two rows for the same path
51 await uploadCells.last().click()
52 - await pageAdmin.getByRole('button', { name: 'Disconnect' }).click();
53 - await pageAdmin.getByRole('button', { name: 'Close' }).click();
52 + await clickIconBtn('Disconnect', pageAdmin)
53 + await clickIconBtn('Close', pageAdmin)
54 await pageAdmin.close()
55 await page.getByText('Copy links').click();
56 await page.getByText('Operation successful').click();
@@ -81,3 +81,108 @@ const NETWORK_PRESETS = {
81 connectionType: 'cellular2g',
82 },
83 } as const;
84 +
85 +// some interactions, no screenshots
86 +test('admin2', async ({ page }) => {
87 + const isPhone = await loginAdmin(page)
88 +
89 + await clickAdminMenu(page, 'Accounts')
90 + await expect(page.getByText('admins', { exact: true })).toBeVisible()
91 + await page.getByRole('button', { name: 'Add' }).click()
92 + await page.getByRole('menuitem', { name: 'user' }).click()
93 + const usernameField = page.getByRole('textbox', { name: 'Username' })
94 + const passwordField = page.getByRole('textbox', { name: 'Password', exact: true })
95 + await usernameField.fill('admin2-temp-user')
96 + await passwordField.fill('admin2-temp-pass')
97 + await page.getByRole('textbox', { name: 'Repeat password' }).fill('admin2-temp-pass')
98 + await expect(usernameField).toHaveValue('admin2-temp-user')
99 + const adminAccess = page.getByRole('checkbox', { name: 'Admin-panel access' })
100 + await adminAccess.check()
101 + await expect(adminAccess).toBeChecked()
102 + await page.getByRole('textbox', { name: 'Notes' }).fill('admin2 expanded interactions')
103 + if (isPhone)
104 + await clickIconBtn('Close', page)
105 +
106 + await clickAdminMenu(page, 'Options')
107 + await expect(page.getByText('Correctly working on port')).toBeVisible()
108 + await page.getByRole('button', { name: 'Reload' }).click()
109 + await page.getByRole('row', { name: /^Blocked/ }).getByRole('button', { name: /Add/ }).click()
110 + const addDialog = page.getByRole('dialog').filter({ hasText: 'Add' })
111 + await addDialog.getByRole('textbox', { name: 'Blocked IP' }).fill('5.6.7.8')
112 + if (!isPhone) {
113 + // This field uses a masked input: selecting from picker is more reliable than typing.
114 + await addDialog.getByRole('button', { name: 'Choose date' }).click()
115 + const picker = page.locator('.MuiPickersPopper-root[role="dialog"]')
116 + await picker.locator('button.MuiPickersDay-root:not([disabled])').first().click()
117 + // Close the popper so it doesn't intercept clicks on the Add dialog buttons.
118 + await page.keyboard.press('Escape')
119 + await expect(addDialog.getByRole('textbox', { name: 'Expire' })).not.toHaveValue('MM/DD/YYYY hh:mm aa')
120 + }
121 + await addDialog.getByRole('button').last().click()
122 + await expect(addDialog).not.toBeVisible()
123 + await expect(page.getByRole('heading', { name: 'Options', exact: true })).toBeVisible() // still on the same page
124 + await expect(page.getByText('5.6.7.8')).toBeVisible()
125 + await page.getByRole('button', { name: 'Reload' }).click()
126 + await expect(page.getByText('5.6.7.8')).not.toBeVisible()
127 +
128 + await clickAdminMenu(page, 'Logs')
129 + await expect(page.getByRole('tab', { name: 'Served', exact: true })).toBeVisible()
130 + const logTabs = page.getByRole('tab')
131 + await logTabs.nth(1).click()
132 + await logTabs.nth(2).click()
133 + await logTabs.nth(3).click()
134 + await logTabs.nth(4).click()
135 + const pauseBtn = page.getByLabel('Pause').getByRole('button')
136 + await expect(pauseBtn).toHaveAttribute('aria-pressed', 'true')
137 + await pauseBtn.click()
138 + await expect(pauseBtn).toHaveAttribute('aria-pressed', 'false')
139 + await pauseBtn.click()
140 + await expect(pauseBtn).toHaveAttribute('aria-pressed', 'true')
141 + const showApisBtn = page.getByRole('button', { name: 'Show APIs' })
142 + await expect(showApisBtn).toHaveAttribute('aria-pressed', 'true')
143 + await showApisBtn.click()
144 + await expect(showApisBtn).toHaveAttribute('aria-pressed', 'false')
145 + await showApisBtn.click()
146 + await expect(showApisBtn).toHaveAttribute('aria-pressed', 'true')
147 + await clickIconBtn('Options', page)
148 + if (!isPhone) {
149 + const logsDialog = page.getByRole('dialog', { name: /Log options/ })
150 + const logApisToggle = logsDialog.getByRole('checkbox', { name: 'Log API requests' })
151 + await expect(logApisToggle).toBeChecked()
152 + await logApisToggle.click()
153 + await expect(logApisToggle).not.toBeChecked()
154 + await logApisToggle.click()
155 + await expect(logApisToggle).toBeChecked()
156 + }
157 + await clickIconBtn('Close', page)
158 +
159 + await clickAdminMenu(page, 'Plugins')
160 + await expect(page.getByText('antibrute')).toBeVisible()
161 + const pluginTabs = page.getByRole('tab')
162 + await pluginTabs.nth(0).click()
163 + if (!isPhone) {
164 + await clickIconBtn('Start download-counter', page)
165 + await clickIconBtn('Options', page)
166 + const whereField = page.getByRole('combobox', { name: 'Where to display counter' })
167 + await whereField.click()
168 + await page.getByRole('option', { name: 'list', exact: true }).click()
169 + const pluginOptionsDialog = page.getByRole('dialog')
170 + const pluginSaveBtn = pluginOptionsDialog.locator('button:has-text("Save")').first()
171 + await expect(pluginSaveBtn).toBeEnabled()
172 + await clickIconBtn('Close', page)
173 + await clickIconBtn('Stop download-counter', page)
174 + }
175 + await pluginTabs.nth(1).click() // get more
176 + await page.getByRole('textbox', { name: 'Search text' }).fill('download')
177 +
178 + await clickAdminMenu(page, 'Custom HTML')
179 + const sectionStyle = page.getByRole('combobox', { name: 'Section Style' })
180 + await expect(sectionStyle).toBeVisible()
181 + await sectionStyle.click()
182 + await page.getByRole('option').nth(1).click()
183 +
184 + await clickAdminMenu(page, 'Internet')
185 + await expect(page.getByText('Server')).toBeVisible()
186 +
187 + await clickAdminMenu(page, 'Logout')
188 +})
playwright.config.ts
+1 -1
@@ -110,7 +110,7 @@ export default defineConfig({
110
111 function getSnapshotBranch() {
112 // CI often runs in detached HEAD, so allow callers to force the logical branch name.
113 - const branchName = process.env.PLAYWRIGHT_SNAPSHOT_BRANCH || getGitBranchName() || 'detached-head'
113 + const branchName = process.env.PLAYWRIGHT_SNAPSHOT_BRANCH || getGitBranchName() || 'main'
114 return branchName.replace(/[^a-zA-Z0-9._-]/g, '_')
115 }
116