fix: (regression 3.0.1) admin/options: couldn't add a "blocked ip" entry (and other problems) with firefox

Massimo Melina committed Jan 24, 2026 at 20:03 UTC fbf5a82ffc36743f92af5f092cf1eb204af57e40
6 files changed +30 -11
e2e/common.ts
+5 -1
@@ -1,4 +1,4 @@
1 -import { test } from '@playwright/test'
1 +import { Page, test } from '@playwright/test'
2 import fs from 'fs'
3
4 export const username = 'rejetto'
@@ -18,4 +18,8 @@ export function clearUploads() {
18 export function resetTimestamp() {
19 fs.utimesSync('tests', t, t);
20 fs.utimesSync('tests/alfa.txt', t, t);
21 +}
22 +
23 +export function forwardConsole(page: Page) {
24 + page.on('console', msg => console.log(msg.type(), msg.text()));
25 }
\ No newline at end of file
e2e/frontend.spec.ts
+11
@@ -253,6 +253,17 @@ test('admin1', async ({ page }) => {
253 await expect(page.getByText('Expire', { exact: true })).toBeVisible() // wait for layout of 'block' table
254 await page.mouse.click(1, 1) // avoid focus inconsistencies
255 await screenshot(page)
256 + { // regression test on dialog navigation not properly working
257 + const blockTable = page.getByRole('grid').filter({ has: page.getByText('Blocked IP', { exact: true }) }).first()
258 + await blockTable.getByRole('button', { name: 'Add' }).click()
259 + const addDialog = page.getByRole('dialog').filter({ hasText: 'Add' })
260 + await addDialog.getByRole('textbox', { name: 'Blocked IP' }).fill('1.2.3.4')
261 + await addDialog.getByRole('button').last().click()
262 + await expect(addDialog).not.toBeVisible()
263 + await expect(page.getByRole('heading', { name: 'Options', exact: true })).toBeVisible() // still on the same page
264 + await expect(blockTable.getByText('1.2.3.4')).toBeVisible()
265 + await page.getByRole('button', { name: 'Reload' }).click() // cancel
266 + }
267
268 await clickMenu('Logs')
269 await dataTableLoading()
e2e/serial.spec.ts
+1
@@ -36,6 +36,7 @@ test('upload1', async ({ page, context, browserName }) => {
36 await page.getByRole('button', { name: 'Edit' }).click();
37 await page.getByRole('textbox').fill(uploadName);
38 await page.getByRole('button', { name: 'Continue' }).click();
39 + await expect(page.getByText(uploadName)).toBeVisible() // rename was effective
40 await page.getByRole('button', { name: 'Send 1 file' }).click();
41 await wait(1500)
42 await pageAdmin.getByRole('cell', { name: uploadName }).click();
frontend/src/login.ts
+1 -3
@@ -134,7 +134,7 @@ export async function loginDialog(closable=true, reloadAfter=true) {
134 const res = await login(u, password, {
135 [ALLOW_SESSION_IP_CHANGE]: ipRef.current?.checked,
136 ...rest
137 - })
137 + }).finally(() => going = false)
138 await close(true)
139 toast(t`Logged in`, 'success')
140 if (res?.redirect)
@@ -145,8 +145,6 @@ export async function loginDialog(closable=true, reloadAfter=true) {
145 } catch (err: any) {
146 await alertDialog(err)
147 usrRef.current?.focus()
148 - } finally {
149 - going = false
148 }
149 }
150
playwright.config.ts
+2 -2
@@ -13,7 +13,7 @@ import { defineConfig, devices } from '@playwright/test';
13 */
14 export default defineConfig({
15 testDir: './e2e',
16 - timeout: 25_000,
16 + timeout: 30_000,
17 fullyParallel: true, // Run tests in files in parallel
18 forbidOnly: !!process.env.CI, // Fail the build on CI if you accidentally left test.only in the source code.
19 retries: process.env.CI ? 2 : 0, // Retry on CI only
@@ -94,7 +94,7 @@ export default defineConfig({
94 },
95 /* Run your local dev server before starting the tests */
96 webServer: [{
97 - command: 'npm run server-for-test',
97 + command: 'npm run server-for-test', // you can run server-for-test-dev instead, but then you need frontend and admin to be running too
98 url: 'http://127.0.0.1:81',
99 reuseExistingServer: !process.env.CI,
100 }, { // launch a second server for tests with an empty/default config
shared/dialogs.ts
+10 -5
@@ -77,13 +77,18 @@ let waitClosing = Promise.resolve()
77 let ignorePopState = false
78 async function back() {
79 ignorePopState = true
80 - let was = history.state
80 + const was = history.state
81 return waitClosing = waitClosing.then(() => new Promise<void>(async res => {
82 - const started = Date.now()
82 + const timeout = Date.now() + 1000
83 + let lastBack = 0
84 while (was === history.state) { // history.back seems to not always be effective, so we loop for it
84 - if (Date.now() - started > 1000) break // emergency brake
85 - history.back()
86 - await wait(10)
85 + const now = Date.now()
86 + if (now > timeout) break // emergency brake
87 + if (now - lastBack > 500) { // after this long time we try again
88 + history.back()
89 + lastBack = now
90 + }
91 + await wait(10) // we wait shorter and loop faster so to exit/resolve asap
92 }
93 res()
94 }))