fix: simplify F95 cookie setup and constrain auth dialog layout
CI / frontend (push) Failing after 17s
CI / backend (push) Failing after 34s

This commit is contained in:
mrleo1nid committed 2026-10-26 15:58:40 +03:00
1 parent 219005d16a
commit ad622389bd
6 files changed
+169 -41

No files matched your search

+18 -1
View File
@@ -1,3 +1,5 @@
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from pydantic import BaseModel, SecretStr from pydantic import BaseModel, SecretStr
@@ -16,7 +18,10 @@ class F95In(BaseModel):
class F95SessionIn(BaseModel): class F95SessionIn(BaseModel):
cookie: SecretStr cookie: SecretStr = SecretStr("") # Compatibility with previously saved clients.
xf_user: SecretStr = SecretStr("")
xf_session: SecretStr = SecretStr("")
cf_clearance: SecretStr = SecretStr("")
user_agent: str = "" user_agent: str = ""
@@ -63,6 +68,18 @@ def require_import(user: CurrentUser) -> None:
@router.put("/f95/session", status_code=204) @router.put("/f95/session", status_code=204)
async def save_f95_session(body: F95SessionIn, user: CurrentUser, db: DB) -> None: async def save_f95_session(body: F95SessionIn, user: CurrentUser, db: DB) -> None:
require_import(user) require_import(user)
values = {key: getattr(body, key).get_secret_value().strip()
for key in ("xf_user", "xf_session", "cf_clearance")}
if any(values.values()):
if not (values["xf_user"] or values["xf_session"]):
raise HTTPException(422, "Добавьте значение xf_user или xf_session для входа в F95zone")
if any(len(value) > 8192 or any(ord(c) < 33 or ord(c) > 126 or c == ";" for c in value)
for value in values.values()):
raise HTTPException(422, "Скопируйте только значение cookie, без имени и дополнительных строк")
# DevTools can display decoded values. Preserve existing escapes and encode delimiters.
cookie = "; ".join(f"{key}={quote(value, safe='%') if key.startswith('xf_') else value}"
for key, value in values.items() if value)
else:
cookie = body.cookie.get_secret_value().strip() cookie = body.cookie.get_secret_value().strip()
if cookie.lower().startswith("cookie:"): if cookie.lower().startswith("cookie:"):
cookie = cookie[7:].strip() cookie = cookie[7:].strip()
+27
View File
@@ -214,6 +214,33 @@ def test_invalid_session_not_saved_or_echoed(cookie):
assert "xf_user" not in error.value.detail assert "xf_user" not in error.value.detail
@pytest.mark.parametrize(("values", "expected"), [
({"xf_user": "123,remember"}, "xf_user=123%2Cremember"),
({"xf_user": "123%2Cremember"}, "xf_user=123%2Cremember"),
({"xf_session": "session-token"}, "xf_session=session-token"),
({"xf_user": "remember", "xf_session": "session", "cf_clearance": "cf.signature=="},
"xf_user=remember; xf_session=session; cf_clearance=cf.signature=="),
])
def test_individual_cookie_values_preserve_encoded_and_decoded_devtools_values(values, expected):
user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None)
db = SimpleNamespace(commit=AsyncMock())
asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(**values), user, db))
assert open_session(1, user.f95_session) == (expected, "")
@pytest.mark.parametrize("values", [
{"cf_clearance": "only-cloudflare"},
{"xf_user": "one; xf_session=two"},
{"xf_user": "one\nsecond-line"},
])
def test_individual_cookie_fields_reject_missing_login_and_multiple_values(values):
user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None)
db = SimpleNamespace(commit=AsyncMock())
with pytest.raises(metadata.HTTPException):
asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(**values), user, db))
assert user.f95_session is None
@pytest.mark.parametrize("schema", [GameIn, GameUpdateIn]) @pytest.mark.parametrize("schema", [GameIn, GameUpdateIn])
def test_cover_hash_cannot_be_a_path_or_arbitrary_blob(schema): def test_cover_hash_cannot_be_a_path_or_arbitrary_blob(schema):
for value in ("../cover.webp", "a" * 64 + ".html", "invalid"): for value in ("../cover.webp", "a" * 64 + ".html", "invalid"):
+18 -5
View File
@@ -12,13 +12,26 @@ Cookie привязаны к текущему пользователю прил
Чтобы добавить сессию: Чтобы добавить сессию:
1. Войдите в свой аккаунт F95zone в браузере и откройте тему игры. 1. Войдите в свой аккаунт F95zone в браузере и откройте тему игры.
2. В инструментах разработчика, на вкладке Network, выберите запрос страницы 2. Нажмите F12 → «Приложение» (Application) → «Хранилище» → «Файлы cookie» →
темы и скопируйте значение заголовка запроса `Cookie`. `https://f95zone.to`.
3. В окне «Авторизация F95zone» вставьте значение в поле Cookie целиком, 3. Выберите строку `xf_user` и скопируйте только содержимое колонки «Значение»
включая `xf_user`, `xf_session`, `cf_clearance` и остальные cookie, если они есть. в одноимённое поле окна авторизации. Если копируете из нижней панели Cookie Value,
При необходимости вставьте `User-Agent` из того же запроса в соседнее поле. отключите «Показать декодированные параметры URL». При необходимости добавьте
`xf_session` таким же способом. Имена cookie, домен, путь и срок действия копировать не нужно.
4. Нажмите «Сохранить и повторить». Импорт повторится с вашими cookie. 4. Нажмите «Сохранить и повторить». Импорт повторится с вашими cookie.
`xf_user` — токен «запомнить меня», `xf_session` — идентификатор текущей сессии.
В XenForo вход может подтверждаться одним из них; начните с `xf_user`, либо используйте
`xf_session`, если `xf_user` отсутствует. Это не гарантирует доступ через защиту F95zone.
См. [пояснение разработчика XenForo](https://xenforo.com/community/threads/authentication-from-a-node-application.189858/).
`xf_csrf` и `TSVB_UID` для этого импорта не запрашиваются.
Если сайт блокирует запрос, откройте «Если F95zone блокирует запрос» и добавьте
`cf_clearance` из той же таблицы. Необязательный User-Agent находится в Network →
запрос страницы темы → Request Headers → User-Agent.
`cf_clearance` привязан к посетителю и устройству; перенос cookie из браузера на сервер
может не сработать ([документация Cloudflare](https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/)).
Cookie используется для запроса темы и скачивания картинки. Сессия хранится Cookie используется для запроса темы и скачивания картинки. Сессия хранится
зашифрованной в записи вашего пользователя; другие пользователи её не используют. зашифрованной в записи вашего пользователя; другие пользователи её не используют.
Значения не возвращаются через API и не сохраняются в localStorage. Значения не возвращаются через API и не сохраняются в localStorage.
+12
View File
@@ -708,6 +708,18 @@ input[type="file"] { padding: 0.4rem; }
animation: pop 0.15s ease-out; animation: pop 0.15s ease-out;
} }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.6rem; margin-top: var(--space-2); } .modal-actions { display: flex; justify-content: flex-end; gap: 0.6rem; margin-top: var(--space-2); }
.f95-modal {
width: min(520px, 100%);
min-width: 0;
max-height: calc(100dvh - 2 * var(--space-4));
overflow-y: auto;
overflow-wrap: anywhere;
}
.f95-modal > * { flex-shrink: 0; min-width: 0; }
.f95-modal .modal-actions { flex-wrap: wrap; }
.f95-modal .modal-actions .btn { flex: 1 1 180px; min-width: 0; white-space: normal; }
.f95-modal details .field { margin-top: var(--space-3); }
.f95-modal ol { padding-left: 1.25rem; margin-block: var(--space-2); }
.toasts { .toasts {
position: fixed; position: fixed;
+55 -25
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api } from "../api"; import { api } from "../api";
@@ -13,6 +13,9 @@ export interface F95Meta {
metadata_available: boolean; metadata_available: boolean;
} }
interface CookieValues { xf_user: string; xf_session: string; cf_clearance: string }
const EMPTY_COOKIES: CookieValues = { xf_user: "", xf_session: "", cf_clearance: "" };
/** Imports editable metadata and a locally cached cover from an F95zone thread. */ /** Imports editable metadata and a locally cached cover from an F95zone thread. */
export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) { export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
@@ -20,12 +23,12 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [warning, setWarning] = useState(""); const [warning, setWarning] = useState("");
const [needsAuth, setNeedsAuth] = useState(false); const [needsAuth, setNeedsAuth] = useState(false);
const [cookie, setCookie] = useState(""); const [cookies, setCookies] = useState<CookieValues>(EMPTY_COOKIES);
const [agent, setAgent] = useState(""); const [agent, setAgent] = useState("");
const decline = () => { const decline = () => {
setNeedsAuth(false); setNeedsAuth(false);
setCookie(""); setCookies(EMPTY_COOKIES);
setAgent(""); setAgent("");
}; };
@@ -49,8 +52,8 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
await api.put("/api/metadata/f95/session", { cookie, user_agent: agent }); await api.put("/api/metadata/f95/session", { ...cookies, user_agent: agent });
setCookie(""); setCookies(EMPTY_COOKIES);
if (url.trim()) await load(); if (url.trim()) await load();
else decline(); else decline();
} catch (e) { } catch (e) {
@@ -92,38 +95,43 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setNeedsAuth(true)}>Cookie F95zone</button> <button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setNeedsAuth(true)}>Cookie F95zone</button>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => void forget()}>Забыть cookie F95zone</button> <button type="button" className="btn btn-ghost" disabled={busy} onClick={() => void forget()}>Забыть cookie F95zone</button>
</div> </div>
{needsAuth && createPortal(<F95SessionDialog busy={busy} cookie={cookie} agent={agent} {needsAuth && createPortal(<F95SessionDialog busy={busy} cookies={cookies} agent={agent}
setCookie={setCookie} setAgent={setAgent} error={error} warning={warning} setCookies={setCookies} setAgent={setAgent} error={error} warning={warning}
onDecline={decline} onSave={() => void saveSession()} />, document.body)} onDecline={decline} onSave={() => void saveSession()} />, document.body)}
</div> </div>
); );
} }
function F95SessionDialog({ busy, cookie, agent, setCookie, setAgent, error, warning, onDecline, onSave }: { function F95SessionDialog({ busy, cookies, agent, setCookies, setAgent, error, warning, onDecline, onSave }: {
busy: boolean; cookie: string; agent: string; error: string; warning: string; busy: boolean; cookies: CookieValues; agent: string; error: string; warning: string;
setCookie: (value: string) => void; setAgent: (value: string) => void; setCookies: (value: CookieValues) => void; setAgent: (value: string) => void;
onDecline: () => void; onSave: () => void; onDecline: () => void; onSave: () => void;
}) { }) {
const dialog = useRef<HTMLDivElement>(null); const dialog = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const userLabel = useId();
const canSave = !!(cookies.xf_user.trim() || cookies.xf_session.trim());
useEffect(() => { useEffect(() => {
const previous = document.activeElement as HTMLElement | null; const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
input.current?.focus(); input.current?.focus();
return () => previous?.focus(); return () => { document.body.style.overflow = overflow; previous?.focus(); };
}, []); }, []);
return <div className="modal-backdrop" onMouseDown={(e) => { return <div className="modal-backdrop" onMouseDown={(e) => {
if (!busy && e.target === e.currentTarget) onDecline(); if (!busy && e.target === e.currentTarget) onDecline();
}}> }}>
<div ref={dialog} className="modal" role="dialog" aria-modal="true" aria-label="Авторизация F95zone" <div ref={dialog} className="modal f95-modal" role="dialog" aria-modal="true" aria-label="Авторизация F95zone"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape" && !busy) { e.stopPropagation(); onDecline(); } if (e.key === "Escape" && !busy) { e.stopPropagation(); onDecline(); }
if (e.key === "Enter" && e.target instanceof HTMLInputElement) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
if (!busy && cookie.trim()) onSave(); if (!busy && canSave) onSave();
} }
if (e.key === "Tab") { if (e.key === "Tab") {
const controls = dialog.current?.querySelectorAll<HTMLElement>("input:not(:disabled), button:not(:disabled), summary"); const controls = Array.from(dialog.current?.querySelectorAll<HTMLElement>("input:not(:disabled), button:not(:disabled), summary") ?? [])
.filter((element) => element.getClientRects().length > 0);
if (!controls?.length) return; if (!controls?.length) return;
const first = controls[0], last = controls[controls.length - 1]; const first = controls[0], last = controls[controls.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
@@ -131,28 +139,50 @@ function F95SessionDialog({ busy, cookie, agent, setCookie, setAgent, error, war
} }
}}> }}>
<h2>Авторизация F95zone</h2> <h2>Авторизация F95zone</h2>
<p className="muted">Для превью может потребоваться вход. Если cookie отсутствуют или истекли, вставьте новые. <p className="muted small">Добавьте cookie своего аккаунта для загрузки превью.
Можно отказаться и заполнить данные игры вручную; уже загруженный текст останется в форме.</p> Можно пропустить: уже загруженные данные останутся в форме.</p>
<details> <details>
<summary>Где взять cookie</summary> <summary>Где взять cookie</summary>
<p className="small">Войдите на f95zone.to, откройте тему игры и инструменты разработчика → Network. <ol className="small">
Выберите запрос страницы темы → Request Headers и скопируйте значение Cookie целиком <li>Войдите на f95zone.to. Нажмите F12 → «Приложение» (Application).</li>
(включая xf_user, xf_session и cf_clearance, если они есть). <li>Слева откройте «Хранилище → Файлы cookie → https://f95zone.to».</li>
User-Agent можно скопировать из того же запроса.</p> <li>Выберите строку <b>xf_user</b> и скопируйте только её «Значение» в поле ниже.
При необходимости так же скопируйте <b>xf_session</b>.</li>
</ol>
<p className="hint">Если копируете из нижней панели Cookie Value, отключите «Показать декодированные параметры URL».
TSVB_UID и xf_csrf для импорта не нужны.</p>
</details> </details>
<label className="field"><span>Cookie</span> <label className="field"><span id={userLabel}>xf_user</span>
<input ref={input} type="password" autoComplete="off" spellCheck={false} maxLength={16384} <input ref={input} type="password" autoComplete="off" spellCheck={false} maxLength={8192}
value={cookie} onChange={(e) => setCookie(e.target.value)} disabled={busy} placeholder="xf_user=…; xf_session=…; cf_clearance=…" /> aria-labelledby={userLabel} aria-describedby={`${userLabel}-hint`}
value={cookies.xf_user} onChange={(e) => setCookies({ ...cookies, xf_user: e.target.value })}
disabled={busy} placeholder="Только значение xf_user" />
<span id={`${userLabel}-hint`} className="hint">Начните с этой cookie. Если её нет, используйте xf_session.</span>
</label>
<label className="field"><span>xf_session (необязательно)</span>
<input type="password" autoComplete="off" spellCheck={false} maxLength={8192}
value={cookies.xf_session} onChange={(e) => setCookies({ ...cookies, xf_session: e.target.value })}
disabled={busy} placeholder="Только значение xf_session" />
</label>
<details>
<summary>Если F95zone блокирует запрос</summary>
<p className="hint">При проверке Cloudflare можно добавить cf_clearance из той же таблицы.
Cookie браузера может не подойти серверу; повторный отказ не всегда означает истёкшую сессию.</p>
<label className="field"><span>cf_clearance (необязательно)</span>
<input type="password" value={cookies.cf_clearance} disabled={busy} maxLength={8192} autoComplete="off" spellCheck={false}
onChange={(e) => setCookies({ ...cookies, cf_clearance: e.target.value })} placeholder="Только значение cf_clearance" />
</label> </label>
<label className="field"><span>User-Agent (необязательно)</span> <label className="field"><span>User-Agent (необязательно)</span>
<input value={agent} onChange={(e) => setAgent(e.target.value)} disabled={busy} maxLength={2048} autoComplete="off" /> <input value={agent} onChange={(e) => setAgent(e.target.value)} disabled={busy} maxLength={2048} autoComplete="off" />
<span className="hint">Его можно скопировать в Network → запрос страницы F95zone → Request Headers → User-Agent.</span>
</label> </label>
</details>
<p className="hint">Cookie сохраняются зашифрованными только в вашем профиле и используются сервером для запросов F95zone.</p> <p className="hint">Cookie сохраняются зашифрованными только в вашем профиле и используются сервером для запросов F95zone.</p>
{warning && <p className="hint" role="status">{warning}</p>} {warning && <p className="hint" role="status">{warning}</p>}
{error && <p className="hint" role="alert">{error}</p>} {error && <p className="hint" role="alert">{error}</p>}
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="btn" disabled={busy} onClick={onDecline}>Продолжить без авторизации</button> <button type="button" className="btn" disabled={busy} onClick={onDecline}>Пропустить</button>
<button type="button" className="btn btn-primary" disabled={busy || !cookie.trim()} onClick={onSave}> <button type="button" className="btn btn-primary" disabled={busy || !canSave} onClick={onSave}>
{busy ? "Проверка…" : "Сохранить и повторить"} {busy ? "Проверка…" : "Сохранить и повторить"}
</button> </button>
</div> </div>
+39 -10
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test"; import { test } from "node:test";
@@ -21,7 +22,9 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" }); bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
let imports = 0, deleted = 0; let imports = 0, deleted = 0;
const saved = []; const saved = [];
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
if (req.url === "/styles.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
if (req.url === "/bundle.js") { if (req.url === "/bundle.js") {
res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return; res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return;
} }
@@ -43,20 +46,44 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
metadata_available: imports !== 5})); return; metadata_available: imports !== 5})); return;
} }
res.setHeader("Content-Type", "text/html"); res.setHeader("Content-Type", "text/html");
res.end('<!doctype html><main></main><script src="/bundle.js"></script>'); res.end('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/styles.css"><main></main><script src="/bundle.js"></script>');
}); });
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
let browser; let browser;
try { try {
browser = await chromium.launch({headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE}); browser = await chromium.launch({headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE});
const page = await browser.newPage(); const page = await browser.newPage({colorScheme: "dark"});
page.setDefaultTimeout(10000);
await page.goto(`http://127.0.0.1:${server.address().port}`); await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.getByPlaceholder("https://f95zone.to/threads/…").fill("https://f95zone.to/threads/example.123/"); await page.getByPlaceholder("https://f95zone.to/threads/…").fill("https://f95zone.to/threads/example.123/");
await page.getByRole("button", {name: "Подтянуть", exact: true}).click(); await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
const dialog = page.getByRole("dialog"); const dialog = page.getByRole("dialog");
await dialog.waitFor(); await dialog.waitFor();
assert.equal(await page.locator("output").textContent(), "Imported title"); assert.equal(await page.locator("output").textContent(), "Imported title");
await dialog.getByRole("button", {name: "Продолжить без авторизации"}).press("Enter"); if (process.env.PGAMES_F95_SCREENSHOT_DIR) {
await page.setViewportSize({width: 676, height: 743});
await page.screenshot({path: `${process.env.PGAMES_F95_SCREENSHOT_DIR}/f95-collapsed.png`});
}
// Exercise the actual stylesheet, expanded help and the overflow shown in the report.
await dialog.getByText("Где взять cookie", {exact: true}).click();
await dialog.getByText("Если F95zone блокирует запрос", {exact: true}).click();
for (const viewport of [{width: 676, height: 743}, {width: 320, height: 568}]) {
await page.setViewportSize(viewport);
const layout = await dialog.evaluate(element => {
const box = element.getBoundingClientRect();
const buttons = [...element.querySelectorAll('.modal-actions button')].map(button => button.getBoundingClientRect());
return {insideViewport: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
noHorizontalOverflow: element.scrollWidth <= element.clientWidth + 1,
buttonsInside: buttons.every(button => button.left >= box.left && button.right <= box.right)};
});
assert.deepEqual(layout, {insideViewport: true, noHorizontalOverflow: true, buttonsInside: true});
await dialog.getByRole("button", {name: "Пропустить", exact: true}).scrollIntoViewIfNeeded();
if (process.env.PGAMES_F95_SCREENSHOT_DIR) {
await page.screenshot({path: `${process.env.PGAMES_F95_SCREENSHOT_DIR}/f95-${viewport.width}.png`});
}
}
await page.setViewportSize({width: 1000, height: 900});
await dialog.getByRole("button", {name: "Пропустить", exact: true}).press("Enter");
await dialog.waitFor({state: "detached"}); await dialog.waitFor({state: "detached"});
assert.equal(imports, 1); assert.equal(imports, 1);
assert.equal(saved.length, 0); assert.equal(saved.length, 0);
@@ -64,17 +91,19 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
await page.getByRole("button", {name: "Подтянуть", exact: true}).click(); await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
await dialog.waitFor(); await dialog.waitFor();
await dialog.getByLabel("Cookie", {exact: true}).fill("xf_user=expired"); await dialog.getByLabel("xf_user", {exact: true}).fill("expired");
await dialog.getByRole("button", {name: "Сохранить и повторить"}).click(); await dialog.getByRole("button", {name: "Сохранить и повторить"}).click();
await page.waitForFunction(() => window.loaded.length === 3); await page.waitForFunction(() => window.loaded.length === 3);
assert.equal(await dialog.count(), 1, "expired cookies keep the replacement dialog open"); assert.equal(await dialog.count(), 1, "expired cookies keep the replacement dialog open");
assert.equal(await dialog.getByLabel("Cookie", {exact: true}).inputValue(), ""); assert.equal(await dialog.getByLabel("xf_user", {exact: true}).inputValue(), "");
await dialog.getByLabel("Cookie", {exact: true}).fill("xf_user=fresh; xf_session=example"); await dialog.getByLabel("xf_user", {exact: true}).fill("fresh");
await dialog.getByLabel("xf_session (необязательно)").fill("example");
await dialog.getByText("Если F95zone блокирует запрос", {exact: true}).click();
await dialog.getByLabel("User-Agent (необязательно)").fill("Test browser"); await dialog.getByLabel("User-Agent (необязательно)").fill("Test browser");
await dialog.getByLabel("Cookie", {exact: true}).press("Enter"); await dialog.getByLabel("xf_user", {exact: true}).press("Enter");
await dialog.waitFor({state: "detached"}); await dialog.waitFor({state: "detached"});
assert.deepEqual(saved, [{cookie: "xf_user=expired", user_agent: ""}, assert.deepEqual(saved, [{xf_user: "expired", xf_session: "", cf_clearance: "", user_agent: ""},
{cookie: "xf_user=fresh; xf_session=example", user_agent: "Test browser"}]); {xf_user: "fresh", xf_session: "example", cf_clearance: "", user_agent: "Test browser"}]);
assert.equal(await page.evaluate(() => window.loaded.at(-1).cover_hash), "cover.webp"); assert.equal(await page.evaluate(() => window.loaded.at(-1).cover_hash), "cover.webp");
assert.equal(await page.evaluate(() => window.submits), 0); assert.equal(await page.evaluate(() => window.submits), 0);
assert.equal(await page.evaluate(() => localStorage.length), 0); assert.equal(await page.evaluate(() => localStorage.length), 0);
@@ -87,7 +116,7 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
await page.getByRole("button", {name: "Подтянуть", exact: true}).click(); await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
await dialog.waitFor(); await dialog.waitFor();
assert.equal(await page.locator("output").textContent(), "Manual title", "login-only response cannot erase manual fields"); assert.equal(await page.locator("output").textContent(), "Manual title", "login-only response cannot erase manual fields");
await dialog.getByLabel("Cookie", {exact: true}).press("Escape"); await dialog.getByLabel("xf_user", {exact: true}).press("Escape");
await dialog.waitFor({state: "detached"}); await dialog.waitFor({state: "detached"});
assert.equal(imports, 5); assert.equal(imports, 5);
assert.equal(saved.length, 2); assert.equal(saved.length, 2);