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).
421 lines
15 KiB
Python
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"]
|