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