Files
hermes-webui/tests/test_4676_project_new_session_shortcuts.py
nesquena-hermes b098e5c658 feat(#4676): gate per-project quick-create buttons behind opt-in setting
Add a default-OFF 'project_quick_create_buttons' setting that gates the
per-project '+' quick-create buttons on sidebar project chips. The chip
render site only calls _attachProjectQuickCreateButton when the new
window._projectQuickCreate runtime flag is enabled, so the buttons do
not appear unless the user opts in via Settings.

Mirrors the large_text_paste_as_attachment opt-in plumbing (defaults,
allowlist, settings collect/restore/save, window hydration at both sites,
checkbox row, i18n across all locales).
2026-06-30 19:49:07 +00:00

421 lines
15 KiB
Python

"""Regression coverage for #4676: project-scope quick conversation creation."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = ROOT / "static" / "sessions.js"
STYLE_CSS = ROOT / "static" / "style.css"
NODE = shutil.which("node")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _extract_function(source: str, name: str) -> str:
marker = f"function {name}("
start = source.find(marker)
assert start >= 0, f"{name} function not found in static/sessions.js"
brace = source.find("{", start)
assert brace >= 0, f"{name} declaration has no opening brace"
depth = 0
for idx in range(brace, len(source)):
ch = source[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : idx + 1]
raise AssertionError(f"{name} function body not closed")
def test_new_session_uses_explicit_project_override_before_active_filter():
src = _read(SESSIONS_JS)
assert "Object.prototype.hasOwnProperty.call(options,'project_id')" in src
assert "reqBody.project_id=options.project_id" in src
def test_quick_create_button_attaches_filter_align_and_request_path():
src = _read(SESSIONS_JS)
helper = _extract_function(src, "_attachProjectQuickCreateButton")
assert "project-chip-quick-create" in helper
assert "_setActiveProjectFilter(project.project_id)" in helper
assert "newSession(false,{project_id:project.project_id})" in helper
assert "if(_newSessionInFlight)" in helper
assert "_setActiveProjectFilter(previousProject)" in helper
assert "btn.ondblclick" in helper
assert "btn.oncontextmenu" in helper
assert "btn.ontouchstart" in helper
assert "btn.ontouchend" in helper
def test_quick_create_button_render_is_gated_off_by_default():
"""#4676 quick-create buttons must be opt-in: the chip render site only
attaches the per-project '+' button when window._projectQuickCreate is set."""
src = _read(SESSIONS_JS)
assert "if(window._projectQuickCreate) _attachProjectQuickCreateButton(chip,p);" in src
# The attach call must never run unconditionally at the render site.
assert "\n _attachProjectQuickCreateButton(chip,p);" not in src
def test_project_quick_create_styles_exist_and_are_discrete_to_pointer_layouts():
css = _read(STYLE_CSS)
assert ".project-chip-quick-create" in css
assert ".project-chip:hover .project-chip-quick-create" in css
assert ".project-chip:focus-within .project-chip-quick-create" in css
assert ".project-chip-quick-create:hover" in css
assert "@media (hover:none) and (pointer:coarse)" in css
def _run_new_session_case(options, active_project=None):
_DRIVER = r"""
const fs = require('fs');
const [path, argsJson] = process.argv.slice(-2);
const args = JSON.parse(argsJson);
const src = fs.readFileSync(path, 'utf8');
function extractAsyncFunction(source, name) {
const marker = `async function ${name}(`;
const start = source.indexOf(marker);
if (start < 0) throw new Error(name + ' not found');
const brace = source.indexOf('{', source.indexOf(')', start));
let depth = 0;
for (let i = brace; i < source.length; i++) {
if (source[i] === '{') depth += 1;
else if (source[i] === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
}
throw new Error('function body not closed for ' + name);
}
const newSessionSrc = extractAsyncFunction(src, 'newSession');
globalThis.window = globalThis;
globalThis.document = {
baseURI: 'http://example.test/',
createElement(tag) {
const node = {
tagName: String(tag || '').toUpperCase(),
children: [],
appendChild(child) { this.children.push(child); },
textContent: '',
value: '',
selectedOptions: [{ dataset: { provider: '' } }],
dataset: {},
};
return node;
},
};
globalThis.localStorage = { getItem: () => null, setItem: () => {} };
globalThis.history = { replaceState: () => {} };
globalThis.NO_PROJECT_FILTER = '__none__';
globalThis._activeProject = args.activeProject;
globalThis._sessionSourceFilter = 'webui';
globalThis._newSessionInFlight = null;
globalThis._messagesTruncated = false;
globalThis._oldestIdx = 0;
globalThis.INFLIGHT = {};
globalThis.S = {
session: args.session || null,
toolCalls: [],
messages: [],
activeProfile: 'default',
_pendingSessionToolsets: null,
_profileSwitchWorkspace: null,
_profileDefaultWorkspace: null,
};
globalThis._defaultModel = null;
globalThis._activeProvider = 'openai';
globalThis._emptyComposerModelOverride = null;
globalThis._readPersistedModelState = () => null;
globalThis._readEmptyComposerModelOverride = () => null;
globalThis._clearEmptyComposerModelOverride = () => {};
globalThis.$ = (id) => (id === 'modelSelect' ? { value: 'gpt-4', selectedOptions: [{ dataset: { provider: 'openai' } }] } : null);
for (const name of [
'_setNewSessionPending', 'updateQueueBadge', '_clearPendingSelections',
'clearLiveToolCards', 'setComposerStatus', 'setStatus', 'updateSendBtn',
'syncTopbar', 'renderMessages', 'startSessionStream', '_setSessionViewedCount',
'_setActiveSessionUrl', '_rememberNewChatDraftSession', '_hydrateTodosFromSession',
'_setLiveAssistantTps', '_syncCtxIndicator', 'showToast'
]) {
globalThis[name] = () => {};
}
globalThis.loadDir = async () => null;
globalThis._applyModelToDropdown = () => true;
globalThis._modelStateForSelect = () => ({ model: 'gpt-4', model_provider: 'openai' });
globalThis._readPersistedModelState = () => null;
globalThis.getModelLabel = (v) => v || '';
globalThis._defaultModel = null;
const calls = [];
globalThis.api = async (_url, opts) => {
calls.push(JSON.parse(opts.body));
return { session: { session_id: 's-1', messages: [], model: 'gpt-4', model_provider: 'openai', workspace: null, message_count: 0, last_usage: {} } };
};
eval(newSessionSrc);
(async () => {
await newSession(false, args.options);
console.log(JSON.stringify({ body: calls[0] || {} }));
})().catch(err => {
console.error(String(err && err.stack ? err.stack : err));
process.exit(1);
});
"""
payload = {
"activeProject": active_project,
"options": options,
"session": {"session_id": "session-1"},
}
result = subprocess.run(
[NODE, "-e", _DRIVER, str(SESSIONS_JS), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}"
)
return json.loads(result.stdout.strip().splitlines()[-1])["body"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_new_session_aligns_project_id_override_when_explicitly_set():
body = _run_new_session_case({"project_id": "explicit-project"}, active_project="active-project")
assert body["project_id"] == "explicit-project"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_new_session_respects_explicit_project_id_none():
body = _run_new_session_case({"project_id": None}, active_project="active-project")
assert "project_id" in body
assert body["project_id"] is None
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_new_session_falls_back_to_active_project_when_override_missing():
body = _run_new_session_case({}, active_project="active-project")
assert body["project_id"] == "active-project"
_HELPER = r"""
const fs = require('fs');
const [sessionsPath, paramsJson] = process.argv.slice(-2);
const sessionsSrc = fs.readFileSync(sessionsPath, 'utf8');
const params = JSON.parse(paramsJson);
function extractFunction(source, name) {
const marker = `function ${name}(`;
const start = source.indexOf(marker);
if (start < 0) throw new Error(name + ' not found');
const brace = source.indexOf('{', start);
let depth = 0;
for (let i = brace; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') {
depth--;
if (depth === 0) return source.slice(start, i + 1);
}
}
throw new Error('function body not closed for ' + name);
}
globalThis.window = globalThis;
globalThis.document = {
createElement(tag) {
return {
tagName: String(tag || '').toUpperCase(),
className: '',
textContent: '',
children: [],
appendChild(child) { this.children.push(child); },
appendChildCallCount: 0,
attributes: {},
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; },
dataset: {},
type: '',
};
},
};
globalThis._setActiveProjectFilter = (projectId) => {
globalThis._activeProject = projectId;
params.filterProjectId = projectId;
params.calls.push({type: 'set-filter', projectId});
};
globalThis._activeProject = params.activeProject;
globalThis.newSession = async (flash, options) => {
if (globalThis._newSessionInFlight) {
params.toasts.push('New conversation already in progress');
return globalThis._newSessionInFlight;
}
if (params.failNewSession) throw new Error(params.failMessage || 'request failed');
params.newSession = {flash, options};
params.calls.push({type: 'new-session', flash, options});
return params.newSessionResult || { session_id: 's-1' };
};
globalThis.showToast = (message) => {
params.toasts.push(String(message || ''));
};
globalThis._newSessionInFlight = params.newSessionInFlightReject
? Promise.reject(new Error(params.newSessionInFlightReject))
: (params.newSessionInFlight
? Promise.resolve(params.newSessionInFlight)
: null);
eval(extractFunction(sessionsSrc, '_attachProjectQuickCreateButton'));
const chip = {
appended: [],
appendChild(child) { this.appended.push(child); },
};
_attachProjectQuickCreateButton(chip, { project_id: params.projectId });
const btn = chip.appended[0];
const ev = {
stopPropagation() { params.stopCount++; },
preventDefault() { params.preventCount++; },
stopImmediatePropagation() { params.stopImmediateCount++; },
};
const touchEv = {
stopPropagation() { params.touchStopCount++; },
preventDefault() { params.touchPreventCount++; },
stopImmediatePropagation() { params.touchStopImmediateCount++; },
};
(async () => {
await btn.onclick(ev);
btn.ondblclick(ev);
btn.oncontextmenu(ev);
btn.ontouchstart(touchEv);
btn.ontouchend(touchEv);
console.log(JSON.stringify({
buttonClass: btn.className,
buttonTag: btn.tagName,
buttonText: btn.textContent,
buttonAriaLabel: btn.getAttribute('aria-label'),
newSession: params.newSession,
filterProjectId: params.filterProjectId,
stopCount: params.stopCount,
preventCount: params.preventCount,
stopImmediateCount: params.stopImmediateCount,
touchStopCount: params.touchStopCount,
touchPreventCount: params.touchPreventCount,
touchStopImmediateCount: params.touchStopImmediateCount,
calls: params.calls,
toasts: params.toasts,
}));
})().catch(err => {
console.error(String(err && err.stack ? err.stack : err));
process.exit(1);
});
"""
def _run_quick_create_case(
project_id="example-project",
*,
active_project="active-project",
fail_new_session=False,
new_session_inflight=None,
new_session_inflight_reject=None,
):
payload = {
"projectId": project_id,
"activeProject": active_project,
"filterProjectId": active_project,
"calls": [],
"stopCount": 0,
"preventCount": 0,
"stopImmediateCount": 0,
"touchStopCount": 0,
"touchPreventCount": 0,
"touchStopImmediateCount": 0,
"failNewSession": fail_new_session,
"newSessionInFlight": new_session_inflight,
"newSessionInFlightReject": new_session_inflight_reject,
"toasts": [],
}
result = subprocess.run(
[NODE, "-e", _HELPER, str(SESSIONS_JS), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
f"node helper failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}"
)
return json.loads(result.stdout.strip().splitlines()[-1])
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_project_chip_quick_create_keeps_active_filter_and_uses_project_override():
out = _run_quick_create_case("project-123")
assert out["buttonClass"] == "project-chip-quick-create"
assert out["buttonTag"] == "BUTTON"
assert out["buttonText"] == "+"
assert out["buttonAriaLabel"] == "New conversation in this project"
assert out["filterProjectId"] == "project-123"
assert out["newSession"] == {"flash": False, "options": {"project_id": "project-123"}}
assert {"type": "set-filter", "projectId": "project-123"} in out["calls"]
assert {"type": "new-session", "flash": False, "options": {"project_id": "project-123"}} in out["calls"]
assert out["stopCount"] >= 3
assert out["preventCount"] >= 3
assert out["stopImmediateCount"] >= 3
assert out["touchStopCount"] >= 2
assert out["touchPreventCount"] == 0
assert out["touchStopImmediateCount"] >= 2
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_project_chip_quick_create_restores_filter_when_new_session_fails():
out = _run_quick_create_case(
"project-123",
active_project="keep-me",
fail_new_session=True,
)
assert out["filterProjectId"] == "keep-me"
assert {"type": "set-filter", "projectId": "project-123"} in out["calls"]
assert {"type": "set-filter", "projectId": "keep-me"} in out["calls"]
assert any(msg.startswith("New conversation failed:") for msg in out["toasts"])
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_project_chip_quick_create_leaves_filter_unchanged_during_inflight_guard():
out = _run_quick_create_case(
"project-123",
active_project="keep-me",
new_session_inflight={"session_id": "existing"},
)
assert out["filterProjectId"] == "keep-me"
assert {"type": "set-filter", "projectId": "project-123"} not in out["calls"]
assert "newSession" not in out
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_project_chip_quick_create_swallows_duplicate_inflight_rejections():
out = _run_quick_create_case(
"project-123",
active_project="keep-me",
new_session_inflight_reject="request failed",
)
assert out["filterProjectId"] == "keep-me"
assert {"type": "set-filter", "projectId": "project-123"} not in out["calls"]
assert out["toasts"] == ["New conversation already in progress"]