331 lines
10 KiB
Python
331 lines
10 KiB
Python
"""Regression tests for #4759: parallelize first-load sidebar boot fetches."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _extract_function(source_text: str, function_name: str) -> str:
|
|
marker = f"async function {function_name}("
|
|
start = source_text.find(marker)
|
|
if start < 0:
|
|
marker = f"function {function_name}("
|
|
start = source_text.find(marker)
|
|
assert start >= 0, f"{function_name}() not found"
|
|
brace_start = source_text.find("{", start)
|
|
assert brace_start >= 0, f"{function_name} body not found"
|
|
|
|
depth = 0
|
|
in_string = None
|
|
escaped = False
|
|
in_line_comment = False
|
|
in_block_comment = False
|
|
|
|
for index in range(brace_start, len(source_text)):
|
|
char = source_text[index]
|
|
nxt = source_text[index + 1] if index + 1 < len(source_text) else ""
|
|
|
|
if in_line_comment:
|
|
if char == "\n":
|
|
in_line_comment = False
|
|
continue
|
|
if in_block_comment:
|
|
if char == "*" and nxt == "/":
|
|
in_block_comment = False
|
|
continue
|
|
if in_string:
|
|
if escaped:
|
|
escaped = False
|
|
elif char == "\\":
|
|
escaped = True
|
|
elif char == in_string:
|
|
in_string = None
|
|
continue
|
|
if char == "/" and nxt == "/":
|
|
in_line_comment = True
|
|
continue
|
|
if char == "/" and nxt == "*":
|
|
in_block_comment = True
|
|
continue
|
|
if char in ("'", '"', "`"):
|
|
in_string = char
|
|
continue
|
|
|
|
if char == "{":
|
|
depth += 1
|
|
elif char == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return source_text[start : index + 1]
|
|
|
|
raise AssertionError(f"could not extract {function_name}()")
|
|
|
|
|
|
def _run_node(script: str):
|
|
completed = subprocess.run(
|
|
[NODE, "-e", script],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr or completed.stdout
|
|
return json.loads(completed.stdout.strip())
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_cold_boot_starts_projects_fetch_before_sessions_resolve():
|
|
"""The project request must start before the session request settles."""
|
|
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
|
|
script = f"""
|
|
const calls = [];
|
|
let resolveSessions;
|
|
let resolveProjects;
|
|
|
|
global._showAllProfiles = false;
|
|
global._allProjects = [];
|
|
global._sessionListHasLoadedOnce = false;
|
|
global.api = (url) => {{
|
|
if (url.startsWith('/api/projects')) {{
|
|
calls.push('projects');
|
|
return new Promise(resolve => {{
|
|
resolveProjects = resolve;
|
|
}});
|
|
}}
|
|
if (url.startsWith('/api/sessions')) {{
|
|
calls.push('sessions');
|
|
return new Promise(resolve => {{
|
|
resolveSessions = resolve;
|
|
}});
|
|
}}
|
|
return Promise.reject(new Error('unexpected endpoint ' + url));
|
|
}};
|
|
{fetch_helper}
|
|
|
|
(async () => {{
|
|
const run = _loadSidebarSessionListPayload('?v=1', {{
|
|
timeoutToast:false,
|
|
timeoutMs:90000,
|
|
retries:1,
|
|
retryTimeouts:true,
|
|
retryStatuses:[502,503,504],
|
|
}});
|
|
|
|
await Promise.resolve();
|
|
const orderAtSettleBoundary = calls.slice();
|
|
|
|
resolveSessions({{
|
|
sessions:[{{session_id:'s1'}}],
|
|
other_profile_count:0,
|
|
archived_count:0,
|
|
archived_webui_count:0,
|
|
archived_cli_count:0,
|
|
}});
|
|
resolveProjects({{projects:[]}});
|
|
const payload = await run;
|
|
console.log(JSON.stringify({{orderAtSettleBoundary,payload,calls:calls}}));
|
|
}})().catch(error => {{
|
|
console.error(error);
|
|
process.exit(1);
|
|
}});
|
|
"""
|
|
|
|
body = _run_node(script)
|
|
assert body["orderAtSettleBoundary"] == ["projects", "sessions"]
|
|
assert body["payload"]["sessData"]["sessions"] == [{"session_id": "s1"}]
|
|
assert body["payload"]["projData"]["projects"] == []
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_project_failure_falls_back_without_blocking_session_payload():
|
|
"""Project endpoint failure should still return session payload and fallback projects."""
|
|
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
|
|
script = f"""
|
|
const calls = [];
|
|
global._showAllProfiles = true;
|
|
global._allProjects = [];
|
|
global._sessionListHasLoadedOnce = false;
|
|
global.api = (url) => {{
|
|
if (url.startsWith('/api/projects')) {{
|
|
calls.push('projects');
|
|
return Promise.reject(new Error('project endpoint down'));
|
|
}}
|
|
if (url.startsWith('/api/sessions')) {{
|
|
calls.push('sessions');
|
|
return Promise.resolve({{
|
|
sessions:[{{session_id:'s1'}}],
|
|
other_profile_count:0,
|
|
archived_count:0,
|
|
archived_webui_count:0,
|
|
archived_cli_count:0,
|
|
}});
|
|
}}
|
|
return Promise.reject(new Error('unexpected endpoint ' + url));
|
|
}};
|
|
{fetch_helper}
|
|
|
|
(async () => {{
|
|
const payload = await _loadSidebarSessionListPayload('?v=1', {{
|
|
timeoutToast:false,
|
|
timeoutMs:90000,
|
|
retries:1,
|
|
retryTimeouts:true,
|
|
retryStatuses:[502,503,504],
|
|
}});
|
|
console.log(JSON.stringify({{calls, payload}}));
|
|
}})().catch(error => {{
|
|
console.error(error);
|
|
process.exit(1);
|
|
}});
|
|
"""
|
|
|
|
body = _run_node(script)
|
|
assert body["calls"] == ["projects", "sessions"]
|
|
assert body["payload"]["sessData"]["sessions"][0]["session_id"] == "s1"
|
|
assert body["payload"]["projData"]["projects"] == []
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_existing_session_request_options_are_preserved():
|
|
"""The cold-load /api/sessions request keeps the existing timeout/retry policy."""
|
|
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
|
|
refresh_fn = _extract_function(SESSIONS_JS, "_runRenderSessionListRefresh")
|
|
script = f"""
|
|
global._SESSION_LIST_BOOT_TIMEOUT_MS = 90000;
|
|
global._sessionListHasLoadedOnce = false;
|
|
global._renderSessionListGen = 1;
|
|
global._profileSwitchListEmbargo = false;
|
|
global._pendingSessionListPayload = null;
|
|
global._showAllProfiles = false;
|
|
global._allProjects = [];
|
|
global._contentSearchResults = [];
|
|
global.$ = () => ({{ value: '' }});
|
|
global._isSessionListUserInteracting = () => false;
|
|
global._schedulePendingSessionListApply = () => {{}};
|
|
global._showSessionListLoadError = error => {{ throw new Error(error && error.message ? error.message : String(error)); }};
|
|
global._applySessionListPayload = () => {{}};
|
|
global._sessionListQueryString = () => '?v=1';
|
|
const calls = [];
|
|
global.api = (url, opts) => {{
|
|
calls.push({{endpoint:url.replace('/api/',''), opts}});
|
|
if(url.startsWith('/api/sessions')){{
|
|
return Promise.resolve({{
|
|
sessions:[{{session_id:'s1'}}],
|
|
other_profile_count:0,
|
|
archived_count:0,
|
|
archived_webui_count:0,
|
|
archived_cli_count:0,
|
|
}});
|
|
}}
|
|
return Promise.resolve({{projects:[]}});
|
|
}};
|
|
{fetch_helper}
|
|
{refresh_fn}
|
|
|
|
(async () => {{
|
|
await _runRenderSessionListRefresh({{}}, 1);
|
|
const sessionCall = calls.find(entry => entry.endpoint === 'sessions?') || calls.find(entry => entry.endpoint.startsWith('sessions'));
|
|
const projectCall = calls.find(entry => entry.endpoint === 'projects?') || calls.find(entry => entry.endpoint.startsWith('projects'));
|
|
console.log(JSON.stringify({{
|
|
sessionOpts: sessionCall ? sessionCall.opts : null,
|
|
projectOpts: projectCall ? projectCall.opts : null,
|
|
calls,
|
|
}}));
|
|
}})().catch(error => {{
|
|
console.error(error);
|
|
process.exit(1);
|
|
}});
|
|
"""
|
|
|
|
body = _run_node(script)
|
|
session_opts = body["sessionOpts"] or {}
|
|
project_opts = body["projectOpts"] or {}
|
|
|
|
assert session_opts["timeoutToast"] is False
|
|
assert session_opts["timeoutMs"] == 90000
|
|
assert session_opts["retries"] == 1
|
|
assert session_opts["retryTimeouts"] is True
|
|
assert session_opts["retryStatuses"] == [502, 503, 504]
|
|
assert project_opts == {"timeoutToast": False}
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_warm_refresh_parallelizes_projects_fetch_and_keeps_minimal_session_opts():
|
|
"""Warm refresh uses minimal session opts, but still starts projects immediately."""
|
|
fetch_helper = _extract_function(SESSIONS_JS, "_loadSidebarSessionListPayload")
|
|
script = f"""
|
|
const calls = [];
|
|
let resolveSessions;
|
|
let resolveProjects;
|
|
global._showAllProfiles = false;
|
|
global._allProjects = [];
|
|
global._sessionListHasLoadedOnce = true;
|
|
global.api = (url, opts) => {{
|
|
if (url.startsWith('/api/projects')) {{
|
|
calls.push({{ endpoint:'projects', opts: opts || null }});
|
|
return new Promise(resolve => {{
|
|
resolveProjects = resolve;
|
|
}});
|
|
}}
|
|
if (url.startsWith('/api/sessions')) {{
|
|
calls.push({{ endpoint:'sessions', opts: opts || null }});
|
|
return new Promise(resolve => {{
|
|
resolveSessions = resolve;
|
|
}});
|
|
}}
|
|
return Promise.reject(new Error('unexpected endpoint ' + url));
|
|
}};
|
|
{fetch_helper}
|
|
|
|
(async () => {{
|
|
const run = _loadSidebarSessionListPayload('?v=1', {{
|
|
timeoutToast:false,
|
|
timeoutMs:90000,
|
|
retries:1,
|
|
retryTimeouts:true,
|
|
retryStatuses:[502,503,504],
|
|
}});
|
|
|
|
await Promise.resolve();
|
|
const orderAtSettleBoundary = calls.map(call => call.endpoint);
|
|
const sessionOpts = calls.find(call => call.endpoint === 'sessions').opts;
|
|
const projectOpts = calls.find(call => call.endpoint === 'projects').opts;
|
|
|
|
resolveSessions({{
|
|
sessions:[{{session_id:'warm-1'}}],
|
|
other_profile_count:0,
|
|
archived_count:0,
|
|
archived_webui_count:0,
|
|
archived_cli_count:0,
|
|
}});
|
|
resolveProjects({{projects:[{{name:'demo'}}]}});
|
|
const payload = await run;
|
|
console.log(JSON.stringify({{
|
|
orderAtSettleBoundary,
|
|
sessionOpts,
|
|
projectOpts,
|
|
payload,
|
|
}}));
|
|
}})().catch(error => {{
|
|
console.error(error);
|
|
process.exit(1);
|
|
}});
|
|
"""
|
|
|
|
body = _run_node(script)
|
|
assert body["orderAtSettleBoundary"] == ["projects", "sessions"]
|
|
assert body["sessionOpts"] == {"timeoutToast": False}
|
|
assert body["projectOpts"] == {"timeoutToast": False}
|
|
assert body["payload"]["sessData"]["sessions"] == [{"session_id": "warm-1"}]
|
|
assert body["payload"]["projData"]["projects"] == [{"name": "demo"}]
|