From 7250adb235ac953fff7db8401cda3d2079596aae Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sat, 27 Jun 2026 06:55:27 +0000 Subject: [PATCH 1/2] fix(webui-v2): port projects settings coverage --- .../components/project-filesystem-panel.js | 3 + .../components/project-workspace-shell.js | 8 +- .../projects/components/projects-grid.js | 27 +- .../js/pages/projects/lib/projects-api.js | 27 +- .../static/js/pages/projects/projects-page.js | 7 +- .../pages/settings/components/channels-tab.js | 1 + .../js/pages/settings/components/tools-tab.js | 12 +- .../static/js/pages/settings/settings-page.js | 22 +- .../test_reborn_webui_v2_legacy_projects.py | 402 ++++++++++++ ..._reborn_webui_v2_legacy_settings_search.py | 606 ++++++++++++++++++ .../test_reborn_webui_v2_legacy_skills.py | 247 +++++++ ...reborn_webui_v2_legacy_tool_permissions.py | 457 +++++++++++++ 12 files changed, 1792 insertions(+), 27 deletions(-) create mode 100644 tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py create mode 100644 tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py create mode 100644 tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py create mode 100644 tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-filesystem-panel.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-filesystem-panel.js index 9e4e40a7484..183d8616ab1 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-filesystem-panel.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-filesystem-panel.js @@ -167,6 +167,9 @@ export function ProjectFilesystemPanel({ threadId }) { key=${entry.path} type="button" onClick=${() => openEntry(entry)} + data-testid="project-filesystem-entry" + data-entry-kind=${entry.kind} + data-entry-path=${entry.path} className="flex w-full items-center gap-3 rounded-[12px] border border-transparent px-3 py-2 text-left hover:border-white/10 hover:bg-white/[0.04]" > <${Icon} diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-workspace-shell.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-workspace-shell.js index 2660deaf69f..998440ffa26 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-workspace-shell.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/project-workspace-shell.js @@ -23,10 +23,14 @@ export function ProjectWorkspaceShell({ const fsThreadId = representativeThreadId(threads); return html` -
+
-

${project.name}

+

${project.name}

${project.description ? html`

${project.description}

` : null} diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/projects-grid.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/projects-grid.js index 080786420a8..2bd50b7caaf 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/projects-grid.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/components/projects-grid.js @@ -12,6 +12,8 @@ import { function ProjectCard({ project, onOpen, t }) { return html`
onOpen(project.id)} role="button" tabIndex=${0} @@ -72,6 +74,7 @@ function ProjectCard({ project, onOpen, t }) {
${formatProjectRelativeTime(project.last_activity)}
<${Button} + data-testid="project-open-workspace" variant="secondary" onClick=${(event) => { event.stopPropagation(); @@ -86,6 +89,8 @@ function ProjectCard({ project, onOpen, t }) { function GeneralProjectCard({ project, onOpen, t }) { return html` <${Panel} + data-testid="project-card" + data-project-id=${project.id} onClick=${() => onOpen(project.id)} role="button" tabIndex=${0} @@ -115,6 +120,7 @@ function GeneralProjectCard({ project, onOpen, t }) { ${compactCount(project.threads_today || 0, "thread")} today
<${Button} + data-testid="project-open-workspace" variant="secondary" onClick=${(event) => { event.stopPropagation(); @@ -140,16 +146,7 @@ export function ProjectsGrid({ const defaultProject = projects.find((project) => project.name === "default"); const scopedProjects = projects.filter((project) => project.name !== "default"); - if (!projects.length && totalProjects > 0) { - return html` - <${EmptyPanel} - title=${t("projects.empty.noMatchTitle")} - description=${t("projects.empty.noMatchDesc")} - /> - `; - } - - if (!projects.length) { + if (!totalProjects) { return html` <${EmptyPanel} title=${t("projects.empty.noneTitle")} @@ -161,7 +158,7 @@ export function ProjectsGrid({ } return html` -
+
${defaultProject && html`<${GeneralProjectCard} project=${defaultProject} onOpen=${onOpenProject} t=${t} />`} <${Panel} className="p-4 sm:p-5"> @@ -175,6 +172,7 @@ export function ProjectsGrid({
onSearchChange(event.target.value)} placeholder=${t("projects.searchPlaceholder")} @@ -189,6 +187,13 @@ export function ProjectsGrid({ ? html`
${scopedProjects.map((project) => html`<${ProjectCard} key=${project.id} project=${project} onOpen=${onOpenProject} t=${t} />`)}
` + : !projects.length + ? html` + <${EmptyPanel} + title=${t("projects.empty.noMatchTitle")} + description=${t("projects.empty.noMatchDesc")} + /> + ` : html` <${EmptyPanel} title=${t("projects.scoped.onlyGeneralTitle")} diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js index 504ac054086..b5d434e5e39 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js @@ -1,7 +1,7 @@ // Project endpoints now call the real WebChat v2 `/api/webchat/v2/projects` -// surface (list/create/read/update/delete + membership ACL). Mission/thread/ -// widget reads remain TODO stubs until the v2 missions/threads-by-project -// endpoints land — the page degrades to empty panels for those. +// surface (list/create/read/update/delete + membership ACL) plus the v2 +// project-filtered thread list. Mission/widget reads remain TODO stubs until +// those v2 project child endpoints land. import { addProjectMember as apiAddProjectMember, @@ -13,6 +13,7 @@ import { removeProjectMember as apiRemoveProjectMember, updateProject as apiUpdateProject, updateProjectMemberRole as apiUpdateProjectMemberRole, + listThreads as apiListThreads, } from "../../../lib/api.js"; // Map a wire `RebornProjectInfo` to the shape the Projects page components @@ -46,6 +47,17 @@ function toPageProject(project) { }; } +function toPageThread(thread) { + if (!thread) return null; + return { + ...thread, + id: thread.thread_id, + state: thread.state || null, + turn_count: thread.turn_count || 0, + updated_at: thread.updated_at || null, + }; +} + export async function fetchProjectsOverview() { const response = await apiListProjects({ limit: 200 }); const projects = (response?.projects || []).map(toPageProject); @@ -94,8 +106,13 @@ export function removeProjectMember(projectId, userId) { export function fetchProjectMissions(_projectId) { return Promise.resolve({ missions: [], todo: true }); } -export function fetchProjectThreads(_projectId) { - return Promise.resolve({ threads: [], todo: true }); +export async function fetchProjectThreads(projectId) { + if (!projectId) return { threads: [] }; + const response = await apiListThreads({ projectId, limit: 200 }); + return { + threads: (response?.threads || []).map(toPageThread).filter(Boolean), + next_cursor: response?.next_cursor || null, + }; } export function fetchProjectWidgets(_projectId) { return Promise.resolve({ widgets: [], todo: true }); diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js index f82dd284e6f..d52f2457d0a 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js @@ -66,13 +66,12 @@ export function ProjectsPage() { }); } - navigate("/chat", { + navigate(nextThreadId ? `/chat/${nextThreadId}` : "/chat", { state: { composerDraft: t("projects.creationDraft"), - threadId: nextThreadId, }, }); - }, [navigate, threadsState]); + }, [navigate, threadsState, t]); const handleOpenThread = React.useCallback((nextThreadId) => { navigate(`/projects/${projectId}/threads/${nextThreadId}`); @@ -85,7 +84,7 @@ export function ProjectsPage() { setChatFlowError(null); try { const newThreadId = await threadsState.createThread(projectId); - navigate("/chat", { state: { threadId: newThreadId } }); + navigate(newThreadId ? `/chat/${newThreadId}` : "/chat"); workspaceState.invalidate(); } catch (error) { setChatFlowError({ diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js index 33f0913d2f9..379ebcff663 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js @@ -35,6 +35,7 @@ function ExtensionChannelCard({ channel, registryEntry }) { const t = useT(); const name = registryEntry?.display_name || + channel?.display_name || channel?.name || registryEntry?.name || t("common.unknown"); diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js index d42b8992fba..00a1b019fb9 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js @@ -107,14 +107,18 @@ function ToolRow({ tool, onPermissionChange, isSaved }) { return html`
${isLocked && - html`<${Icon} - name="lock" - className="h-3.5 w-3.5 shrink-0 text-[var(--v2-text-faint)]" - />`} + html` + <${Icon} + name="lock" + className="h-3.5 w-3.5 text-[var(--v2-text-faint)]" + /> + `}
{ @@ -98,6 +108,16 @@ export function SettingsPage() {
`} + <${SettingsToolbar} + settingsExport=${query.data || null} + onImport=${importSettings} + isImporting=${isImporting} + searchQuery=${searchQuery} + onSearchChange=${setSearchQuery} + onSearchClear=${() => setSearchQuery("")} + canGoBack=${false} + /> + ${tabContent[tab]}
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py new file mode 100644 index 00000000000..b5ce7bc091b --- /dev/null +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py @@ -0,0 +1,402 @@ +"""Legacy project overview coverage ported to Reborn WebChat v2.""" + +import json +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse + +from playwright.async_api import expect + +from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2 +from reborn_webui_harness import ( + reborn_v2_browser, # noqa: F401 - imported fixture + reborn_v2_server, # noqa: F401 - imported fixture +) + + +MOCK_PROJECT_ID = "068f67da-49b6-4f6c-9463-8d243c2cff6c" +PRODUCT_PROJECT_ID = "b1234567-cafe-4000-a000-111111111111" +PROJECT_THREAD_ID = "thread-project-files" +PROJECT_WORKSPACE_FILE_BYTES = b"# Launch Brief\n\nShip the research digest.\n" + +MOCK_PROJECTS = [ + { + "project_id": "default", + "name": "default", + "description": "", + "metadata": {}, + "state": "active", + "role": "owner", + "created_at": "2026-04-12T08:00:00Z", + "updated_at": "2026-04-12T10:30:00Z", + }, + { + "project_id": MOCK_PROJECT_ID, + "name": "AI Research Intelligence", + "description": ( + "Stay informed on the latest AI research with daily paper digests " + "and weekly trend analysis." + ), + "metadata": { + "goals": [ + "Monitor arXiv AI papers daily", + "Generate weekly trend synthesis reports", + ] + }, + "state": "active", + "role": "owner", + "created_at": "2026-04-12T08:45:00Z", + "updated_at": "2026-04-12T09:15:00Z", + }, + { + "project_id": PRODUCT_PROJECT_ID, + "name": "Product Launch Q2", + "description": ( + "Coordinate the Q2 product launch campaign across marketing, " + "engineering, and sales." + ), + "metadata": {"goals": ["Ship v2.0 by June 15", "Hit launch signups"]}, + "state": "active", + "role": "owner", + "created_at": "2026-04-11T08:45:00Z", + "updated_at": "2026-04-12T08:45:00Z", + }, +] + + +async def _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser): + context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) + page = await context.new_page() + project_requests: list[str] = [] + thread_create_requests: list[dict] = [] + project_thread_requests: list[str] = [] + project_file_requests: list[str] = [] + + async def fulfill_json(route, payload, status=200): + await route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + headers={"Cache-Control": "no-store"}, + ) + + async def handle_projects(route): + request = route.request + parsed = urlparse(request.url) + path = parsed.path + + if path == "/api/webchat/v2/projects" and request.method == "GET": + project_requests.append(path) + await fulfill_json(route, {"projects": MOCK_PROJECTS}) + return + + prefix = "/api/webchat/v2/projects/" + if path.startswith(prefix) and request.method == "GET": + project_id = unquote(path.removeprefix(prefix)) + project_requests.append(path) + project = next( + ( + candidate + for candidate in MOCK_PROJECTS + if candidate["project_id"] == project_id + ), + None, + ) + await fulfill_json( + route, + {"project": project}, + status=200 if project is not None else 404, + ) + return + + await route.continue_() + + async def handle_threads(route): + request = route.request + parsed = urlparse(request.url) + path = parsed.path + + if path == "/api/webchat/v2/threads" and request.method == "GET": + query = parse_qs(parsed.query) + if query.get("project_id") == [MOCK_PROJECT_ID]: + project_thread_requests.append(request.url) + await fulfill_json( + route, + { + "threads": [ + { + "thread_id": PROJECT_THREAD_ID, + "title": "Weekly research digest", + "goal": "Summarize launch-readiness signals.", + "thread_type": "chat", + "project_id": MOCK_PROJECT_ID, + "created_at": "2026-04-12T11:30:00Z", + "updated_at": "2026-04-12T12:00:00Z", + } + ], + "next_cursor": None, + }, + ) + return + await fulfill_json(route, {"threads": [], "next_cursor": None}) + return + + if path == "/api/webchat/v2/threads" and request.method == "POST": + body = json.loads(request.post_data or "{}") + thread_create_requests.append(body) + await fulfill_json( + route, + { + "thread": { + "thread_id": "thread-project-scoped", + "title": "Project scoped conversation", + "project_id": body.get("project_id"), + "created_at": "2026-04-12T11:00:00Z", + "updated_at": "2026-04-12T11:00:00Z", + } + }, + ) + return + + if path == "/api/webchat/v2/threads/thread-project-scoped/timeline": + await fulfill_json(route, {"messages": [], "next_cursor": None}) + return + + if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files": + project_file_requests.append(request.url) + query = parse_qs(parsed.query) + if query.get("path") == ["/workspace/reports"]: + await fulfill_json( + route, + { + "entries": [ + { + "name": "launch-brief.md", + "path": "/workspace/reports/launch-brief.md", + "kind": "file", + "size": len(PROJECT_WORKSPACE_FILE_BYTES), + } + ] + }, + ) + return + + await fulfill_json( + route, + { + "entries": [ + { + "name": "reports", + "path": "/workspace/reports", + "kind": "directory", + }, + { + "name": "README.md", + "path": "/workspace/README.md", + "kind": "file", + "size": 42, + }, + ] + }, + ) + return + + if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files/content": + project_file_requests.append(request.url) + await route.fulfill( + status=200, + content_type="text/markdown", + body=PROJECT_WORKSPACE_FILE_BYTES.decode("utf-8"), + headers={"Cache-Control": "no-store"}, + ) + return + + await route.continue_() + + await page.route("**/api/webchat/v2/projects**", handle_projects) + await page.route("**/api/webchat/v2/threads**", handle_threads) + await page.goto(f"{reborn_v2_server}/v2/projects?token={REBORN_V2_AUTH_TOKEN}") + + try: + await expect(page.locator(SEL_V2["projects_grid"])).to_be_visible(timeout=15000) + except AssertionError as error: + body_text = await page.locator("body").inner_text(timeout=1000) + raise AssertionError( + f"Projects grid did not render on {page.url}.\nBody text:\n{body_text}" + ) from error + + return { + "context": context, + "page": page, + "project_requests": project_requests, + "thread_create_requests": thread_create_requests, + "project_thread_requests": project_thread_requests, + "project_file_requests": project_file_requests, + } + + +async def test_reborn_legacy_projects_overview_search_and_open_workspace( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + project_requests = harness["project_requests"] + + default_card = page.locator(SEL_V2["project_card_for"].format(id="default")) + research_card = page.locator( + SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID) + ) + product_card = page.locator( + SEL_V2["project_card_for"].format(id=PRODUCT_PROJECT_ID) + ) + + await expect(default_card).to_be_visible(timeout=5000) + await expect(research_card).to_contain_text("AI Research Intelligence") + await expect(research_card).to_contain_text("weekly trend analysis") + await expect(research_card).to_contain_text("Monitor arXiv AI papers daily") + await expect(product_card).to_contain_text("Product Launch Q2") + + search = page.locator(SEL_V2["projects_search_input"]) + await search.fill("trend synthesis") + await expect(research_card).to_be_visible() + await expect(product_card).to_have_count(0) + await expect(default_card).to_have_count(0) + + await search.fill("") + research_card = page.locator( + SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID) + ) + await research_card.locator(SEL_V2["project_open_workspace"]).click() + + await expect( + page.locator(SEL_V2["project_workspace_for"].format(id=MOCK_PROJECT_ID)) + ).to_be_visible(timeout=10000) + await expect(page.locator(SEL_V2["project_workspace_title"])).to_have_text( + "AI Research Intelligence" + ) + await page.wait_for_url(f"**/v2/projects/{MOCK_PROJECT_ID}**", timeout=5000) + assert "/api/webchat/v2/projects" in project_requests + assert f"/api/webchat/v2/projects/{MOCK_PROJECT_ID}" in project_requests + finally: + await harness["context"].close() + + +async def test_reborn_legacy_projects_search_no_match_can_be_cleared( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + search = page.locator(SEL_V2["projects_search_input"]) + + await search.fill("no-project-matches-this") + await expect( + page.get_by_text("No projects match the current search") + ).to_be_visible(timeout=5000) + await expect(search).to_be_visible() + await expect( + page.locator(SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID)) + ).to_have_count(0) + + await search.fill("") + await expect( + page.locator(SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID)) + ).to_be_visible(timeout=5000) + await expect( + page.locator(SEL_V2["project_card_for"].format(id=PRODUCT_PROJECT_ID)) + ).to_be_visible() + finally: + await harness["context"].close() + + +async def test_reborn_legacy_project_creation_opens_seeded_chat_thread( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + + await page.get_by_role("button", name="New project").click() + await page.wait_for_url("**/v2/chat/thread-project-scoped", timeout=10000) + + composer = page.locator(SEL_V2["chat_composer"]) + await expect(composer).to_be_visible(timeout=10000) + await expect(composer).to_have_value( + "Create a new project for me. I want to set up a project for: ", + timeout=5000, + ) + + assert len(harness["thread_create_requests"]) == 1 + assert "project_id" not in harness["thread_create_requests"][0] + assert harness["thread_create_requests"][0]["client_action_id"] + finally: + await harness["context"].close() + + +async def test_reborn_legacy_project_workspace_starts_scoped_chat_thread( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + + await page.locator( + SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID) + ).locator(SEL_V2["project_open_workspace"]).click() + await expect( + page.locator(SEL_V2["project_workspace_for"].format(id=MOCK_PROJECT_ID)) + ).to_be_visible(timeout=10000) + + await page.get_by_role("button", name="New conversation").click() + await page.wait_for_url("**/v2/chat/thread-project-scoped", timeout=10000) + await expect(page.locator(SEL_V2["chat_composer"])).to_be_visible(timeout=10000) + + assert len(harness["thread_create_requests"]) == 1 + assert harness["thread_create_requests"][0]["project_id"] == MOCK_PROJECT_ID + assert harness["thread_create_requests"][0]["client_action_id"] + finally: + await harness["context"].close() + + +async def test_reborn_legacy_project_workspace_lists_and_downloads_files( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + + await page.locator( + SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID) + ).locator(SEL_V2["project_open_workspace"]).click() + await expect( + page.locator(SEL_V2["project_workspace_for"].format(id=MOCK_PROJECT_ID)) + ).to_be_visible(timeout=10000) + + await expect(page.get_by_text("Weekly research digest")).to_be_visible( + timeout=10000 + ) + reports_entry = page.locator( + SEL_V2["project_filesystem_entry_for"].format(path="/workspace/reports") + ) + await expect(reports_entry).to_be_visible(timeout=10000) + await reports_entry.click() + + launch_brief_entry = page.locator( + SEL_V2["project_filesystem_entry_for"].format( + path="/workspace/reports/launch-brief.md" + ) + ) + await expect(launch_brief_entry).to_be_visible(timeout=10000) + + async with page.expect_download() as download_info: + await launch_brief_entry.click() + download = await download_info.value + assert download.suggested_filename == "launch-brief.md" + assert Path(await download.path()).read_bytes() == PROJECT_WORKSPACE_FILE_BYTES + + assert harness["project_thread_requests"] + assert any("project_id=" in url for url in harness["project_thread_requests"]) + assert any("/files" in url for url in harness["project_file_requests"]) + assert any("/files/content" in url for url in harness["project_file_requests"]) + finally: + await harness["context"].close() diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py new file mode 100644 index 00000000000..fe07db2ddfa --- /dev/null +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py @@ -0,0 +1,606 @@ +"""Legacy settings search coverage ported to Reborn WebChat v2.""" + +import json +from urllib.parse import urlparse + +from playwright.async_api import expect + +from helpers import REBORN_V2_AUTH_TOKEN +from reborn_webui_harness import ( + reborn_v2_browser, # noqa: F401 - imported fixture + reborn_v2_server, # noqa: F401 - imported fixture +) + + +MOCK_TOOL_ENTRIES = [ + { + "key": "agent.auto_approve_tools", + "value": False, + "mutable": True, + "source": "default", + }, + { + "key": "tool.echo", + "value": { + "name": "echo", + "description": "Echo text back for deterministic tests.", + "state": "ask_each_time", + "default_state": "ask_each_time", + "effective_source": "default", + }, + "mutable": True, + "source": "default", + }, + { + "key": "tool.search_web", + "value": { + "name": "search_web", + "description": "Search the web for current answers.", + "state": "always_allow", + "default_state": "ask_each_time", + "effective_source": "override", + }, + "mutable": True, + "source": "override", + }, +] + +MOCK_SKILLS = [ + { + "name": "markdown-helper", + "description": "Formats markdown for deterministic tests.", + "version": "1.0.0", + "trust": "Installed", + "source_kind": "installed", + "keywords": ["markdown"], + "usage_hint": "Use for markdown formatting.", + "can_edit": True, + "can_delete": True, + "auto_activate": True, + }, + { + "name": "workspace-helper", + "description": "Reads workspace context.", + "version": "1.0.0", + "trust": "Trusted", + "source_kind": "workspace", + "keywords": ["workspace"], + "can_edit": False, + "can_delete": False, + }, +] + +MOCK_CHANNEL_EXTENSION = { + "name": "telegram-channel", + "package_ref": {"kind": "extension", "id": "telegram-channel"}, + "display_name": "Telegram Channel", + "kind": "wasm_channel", + "description": "Configured messaging channel.", + "active": True, + "authenticated": True, + "onboarding_state": "ready", +} + +MOCK_MCP_EXTENSION = { + "name": "beta-mcp", + "package_ref": {"kind": "extension", "id": "beta-mcp"}, + "display_name": "Beta MCP", + "kind": "mcp_server", + "description": "Installed MCP server.", + "active": False, + "authenticated": False, +} + + +async def _open_mocked_settings_page( + reborn_v2_server, + reborn_v2_browser, + *, + tab: str, + llm_state: dict | None = None, + llm_requests: list[dict] | None = None, +): + context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) + page = await context.new_page() + browser_messages: list[str] = [] + page.on( + "console", + lambda message: browser_messages.append(f"{message.type}: {message.text}"), + ) + page.on("pageerror", lambda error: browser_messages.append(f"pageerror: {error}")) + + async def fulfill_json(route, payload, status=200): + await route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + headers={"Cache-Control": "no-store"}, + ) + + async def handle_session(route): + request = route.request + path = urlparse(request.url).path + + if path == "/api/webchat/v2/session" and request.method == "GET": + await fulfill_json( + route, + { + "tenant_id": "reborn-v2-e2e", + "user_id": "reborn-v2-e2e-user", + "capabilities": {"operator_webui_config": True}, + "features": {"reborn_projects": False}, + }, + ) + return + + await route.continue_() + + async def handle_settings_tools(route): + request = route.request + path = urlparse(request.url).path + + if path == "/api/webchat/v2/settings/tools" and request.method == "GET": + await fulfill_json(route, {"entries": MOCK_TOOL_ENTRIES}) + return + + await route.continue_() + + async def handle_skills(route): + request = route.request + path = urlparse(request.url).path + + if path == "/api/webchat/v2/skills" and request.method == "GET": + await fulfill_json( + route, + { + "skills": MOCK_SKILLS, + "count": len(MOCK_SKILLS), + "auto_activate_learned": True, + }, + ) + return + + await route.continue_() + + async def handle_extensions(route): + request = route.request + path = urlparse(request.url).path + + if path == "/api/webchat/v2/extensions" and request.method == "GET": + await fulfill_json( + route, + {"extensions": [MOCK_CHANNEL_EXTENSION, MOCK_MCP_EXTENSION]}, + ) + return + + if path == "/api/webchat/v2/extensions/registry" and request.method == "GET": + await fulfill_json(route, {"entries": []}) + return + + await route.continue_() + + async def handle_llm(route): + request = route.request + path = urlparse(request.url).path + method = request.method + + if llm_state is None: + await route.continue_() + return + + def record(kind: str, payload: dict | None = None) -> None: + if llm_requests is not None: + llm_requests.append({"kind": kind, "payload": payload or {}}) + + def request_json() -> dict: + raw = request.post_data or "{}" + return json.loads(raw) + + def provider_from_payload(payload: dict) -> dict: + existing = next( + ( + provider + for provider in llm_state["providers"] + if provider["id"] == payload["id"] + ), + {}, + ) + return { + **existing, + "id": payload["id"], + "description": payload.get("name") or payload["id"], + "adapter": payload.get("adapter") or existing.get("adapter"), + "base_url": payload.get("base_url", existing.get("base_url", "")), + "default_model": payload.get( + "default_model", existing.get("default_model", "") + ), + "builtin": False, + "api_key_set": bool(payload.get("api_key")) + or existing.get("api_key_set", False), + "api_key_required": payload.get("adapter") != "ollama", + "base_url_required": True, + "accepts_api_key": payload.get("adapter") != "ollama", + } + + if path == "/api/webchat/v2/llm/providers" and method == "GET": + await fulfill_json( + route, + { + "providers": llm_state["providers"], + "active": llm_state.get("active"), + }, + ) + return + + if path == "/api/webchat/v2/llm/providers" and method == "POST": + payload = request_json() + record("upsert", payload) + provider = provider_from_payload(payload) + llm_state["providers"] = [ + item for item in llm_state["providers"] if item["id"] != provider["id"] + ] + [provider] + if payload.get("set_active"): + llm_state["active"] = { + "provider_id": provider["id"], + "model": payload.get("model") or provider.get("default_model"), + } + await fulfill_json(route, {"provider": provider}) + return + + if path == "/api/webchat/v2/llm/active" and method == "POST": + payload = request_json() + record("active", payload) + llm_state["active"] = { + "provider_id": payload["provider_id"], + "model": payload["model"], + } + await fulfill_json(route, {"active": llm_state["active"]}) + return + + if path == "/api/webchat/v2/llm/list-models" and method == "POST": + payload = request_json() + record("list_models", payload) + await fulfill_json( + route, + { + "ok": True, + "models": ["acme-fast", "acme-pro"], + "message": "models listed", + }, + ) + return + + if path == "/api/webchat/v2/llm/test-connection" and method == "POST": + payload = request_json() + record("test_connection", payload) + await fulfill_json( + route, + {"ok": True, "message": f"probe ok for {payload.get('model')}"}, + ) + return + + if path.startswith("/api/webchat/v2/llm/providers/") and path.endswith( + "/delete" + ) and method == "POST": + provider_id = ( + path.removeprefix("/api/webchat/v2/llm/providers/") + .removesuffix("/delete") + ) + record("delete", {"provider_id": provider_id}) + llm_state["providers"] = [ + provider + for provider in llm_state["providers"] + if provider["id"] != provider_id + ] + await fulfill_json(route, {"success": True}) + return + + await route.continue_() + + await page.route("**/api/webchat/v2/session", handle_session) + await page.route("**/api/webchat/v2/settings/tools**", handle_settings_tools) + await page.route("**/api/webchat/v2/skills**", handle_skills) + await page.route("**/api/webchat/v2/extensions**", handle_extensions) + await page.route("**/api/webchat/v2/llm/**", handle_llm) + + await page.goto(f"{reborn_v2_server}/v2/settings/{tab}?token={REBORN_V2_AUTH_TOKEN}") + search = page.get_by_placeholder("Search settings...") + try: + await expect(search).to_be_visible(timeout=15000) + except AssertionError as error: + body_text = await page.locator("body").inner_text(timeout=1000) + raise AssertionError( + f"Settings search toolbar did not render on {page.url}.\n" + f"Browser messages: {browser_messages}\n" + f"Body text:\n{body_text}" + ) from error + + return {"context": context, "page": page, "search": search} + + +def _provider_card(page, provider_id: str): + return page.locator( + f"[data-testid='llm-provider-card'][data-provider-id='{provider_id}']" + ) + + +def _mock_llm_state() -> dict: + return { + "active": {"provider_id": "openai", "model": "gpt-4.1-mini"}, + "providers": [ + { + "id": "openai", + "description": "OpenAI API", + "adapter": "open_ai_completions", + "base_url": "https://api.openai.test/v1", + "default_model": "gpt-4.1-mini", + "builtin": True, + "api_key_set": True, + "api_key_required": True, + "base_url_required": False, + "accepts_api_key": True, + }, + { + "id": "anthropic", + "description": "Anthropic API", + "adapter": "anthropic", + "base_url": "", + "default_model": "claude-3-5-sonnet", + "builtin": True, + "api_key_set": False, + "api_key_required": True, + "base_url_required": False, + "accepts_api_key": True, + }, + { + "id": "legacy-local", + "description": "Legacy Local", + "adapter": "open_ai_completions", + "base_url": "http://localhost:11434/v1", + "default_model": "legacy-model", + "builtin": False, + "api_key_set": True, + "api_key_required": True, + "base_url_required": True, + "accepts_api_key": True, + }, + ], + } + + +async def test_reborn_legacy_settings_tools_search_and_clear( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_settings_page( + reborn_v2_server, + reborn_v2_browser, + tab="tools", + ) + try: + page = harness["page"] + search = harness["search"] + + await expect(page.get_by_text("echo", exact=True)).to_be_visible(timeout=5000) + await expect(page.get_by_text("search_web", exact=True)).to_be_visible(timeout=5000) + + await search.fill("echo") + await expect(page.get_by_text("echo", exact=True)).to_be_visible() + await expect(page.get_by_text("search_web", exact=True)).to_have_count(0) + await expect(page.get_by_text("1 / 2")).to_be_visible() + + await page.get_by_role("button", name="Clear search").click() + await expect(search).to_have_value("") + await expect(page.get_by_text("search_web", exact=True)).to_be_visible() + + await search.fill("missing-tool") + await expect(page.get_by_text("No tools match the filter.")).to_be_visible() + finally: + await harness["context"].close() + + +async def test_reborn_legacy_settings_skills_search_empty_state( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_settings_page( + reborn_v2_server, + reborn_v2_browser, + tab="skills", + ) + try: + page = harness["page"] + search = harness["search"] + + await expect(page.get_by_text("markdown-helper", exact=True)).to_be_visible( + timeout=5000 + ) + await expect(page.get_by_text("workspace-helper", exact=True)).to_be_visible( + timeout=5000 + ) + + await search.fill("workspace") + await expect(page.get_by_text("workspace-helper", exact=True)).to_be_visible() + await expect(page.get_by_text("markdown-helper", exact=True)).to_have_count(0) + + await search.fill("no-such-skill") + await expect(page.get_by_text('No settings match "no-such-skill"')).to_be_visible() + finally: + await harness["context"].close() + + +async def test_reborn_legacy_settings_channels_search( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_settings_page( + reborn_v2_server, + reborn_v2_browser, + tab="channels", + ) + try: + page = harness["page"] + search = harness["search"] + + await expect(page.get_by_text("Telegram Channel", exact=True)).to_be_visible( + timeout=5000 + ) + await expect(page.get_by_text("Beta MCP", exact=True)).to_be_visible(timeout=5000) + + await search.fill("telegram") + await expect(page.get_by_text("Telegram Channel", exact=True)).to_be_visible() + await expect(page.get_by_text("Beta MCP", exact=True)).to_have_count(0) + + await search.fill("nothing-matches-this") + await expect( + page.get_by_text('No settings match "nothing-matches-this"') + ).to_be_visible() + finally: + await harness["context"].close() + + +async def test_reborn_legacy_settings_inference_add_test_and_activate_provider( + reborn_v2_server, reborn_v2_browser +): + llm_state = _mock_llm_state() + llm_requests: list[dict] = [] + harness = await _open_mocked_settings_page( + reborn_v2_server, + reborn_v2_browser, + tab="inference", + llm_state=llm_state, + llm_requests=llm_requests, + ) + try: + page = harness["page"] + + await expect(page.get_by_text("LLM provider", exact=True)).to_be_visible( + timeout=5000 + ) + await expect( + page.get_by_text("gpt-4.1-mini", exact=True).first + ).to_be_visible() + await expect(_provider_card(page, "openai")).to_be_visible(timeout=5000) + await expect(_provider_card(page, "legacy-local")).to_be_visible(timeout=5000) + + await page.get_by_role("button", name="Add provider").click() + dialog = page.get_by_role("dialog") + await expect(dialog.get_by_role("heading", name="New provider")).to_be_visible() + + await dialog.get_by_label("Display name").fill("Acme LLM") + await expect(dialog.get_by_label("Provider ID")).to_have_value("acme-llm") + await dialog.get_by_label("Base URL").fill("https://llm.acme.test/v1") + await dialog.get_by_label("API key").fill("acme-secret") + await dialog.get_by_label("Default model").fill("stale-model") + + await dialog.get_by_role("button", name="Fetch models").click() + await expect(dialog.get_by_text("2 models found.")).to_be_visible(timeout=5000) + await dialog.get_by_role("combobox").nth(1).select_option("acme-pro") + + await dialog.get_by_role("button", name="Test connection").click() + await expect(dialog.get_by_text("probe ok for acme-pro")).to_be_visible( + timeout=5000 + ) + + await dialog.get_by_role("button", name="Save").click() + await expect(page.get_by_text('Added provider "Acme LLM".')).to_be_visible( + timeout=5000 + ) + + acme_card = _provider_card(page, "acme-llm") + await expect(acme_card).to_be_visible(timeout=5000) + await acme_card.get_by_role("button", name="Use").click() + await expect(page.get_by_text("Switched to Acme LLM.")).to_be_visible( + timeout=5000 + ) + await expect(page.get_by_text("acme-llm", exact=True).first).to_be_visible() + await expect(page.get_by_text("acme-pro", exact=True).first).to_be_visible() + + assert { + "kind": "list_models", + "payload": { + "adapter": "open_ai_completions", + "base_url": "https://llm.acme.test/v1", + "provider_id": "acme-llm", + "provider_type": "custom", + "model": "stale-model", + "api_key": "acme-secret", + }, + } in llm_requests + assert { + "kind": "test_connection", + "payload": { + "adapter": "open_ai_completions", + "base_url": "https://llm.acme.test/v1", + "provider_id": "acme-llm", + "provider_type": "custom", + "model": "acme-pro", + "api_key": "acme-secret", + }, + } in llm_requests + assert any( + request["kind"] == "upsert" + and request["payload"]["id"] == "acme-llm" + and request["payload"]["default_model"] == "acme-pro" + and request["payload"]["api_key"] == "acme-secret" + for request in llm_requests + ) + assert { + "kind": "active", + "payload": {"provider_id": "acme-llm", "model": "acme-pro"}, + } in llm_requests + finally: + await harness["context"].close() + + +async def test_reborn_legacy_settings_inference_edit_and_delete_custom_provider( + reborn_v2_server, reborn_v2_browser +): + llm_state = _mock_llm_state() + llm_requests: list[dict] = [] + harness = await _open_mocked_settings_page( + reborn_v2_server, + reborn_v2_browser, + tab="inference", + llm_state=llm_state, + llm_requests=llm_requests, + ) + try: + page = harness["page"] + legacy_card = _provider_card(page, "legacy-local") + await expect(legacy_card).to_be_visible(timeout=5000) + + await legacy_card.get_by_test_id("llm-provider-disclosure").click() + await legacy_card.get_by_role("button", name="Edit").click() + dialog = page.get_by_role("dialog") + await expect( + dialog.get_by_role("heading", name="Edit provider") + ).to_be_visible() + + await dialog.get_by_label("Base URL").fill("http://127.0.0.1:11435/v1") + await dialog.get_by_label("Default model").fill("legacy-v2") + await dialog.get_by_role("button", name="Save").click() + await expect( + page.get_by_text('Updated provider "Legacy Local".') + ).to_be_visible(timeout=5000) + await expect(legacy_card.get_by_text("legacy-v2", exact=True)).to_be_visible( + timeout=5000 + ) + + edit_request = next( + request + for request in llm_requests + if request["kind"] == "upsert" + and request["payload"].get("id") == "legacy-local" + ) + assert edit_request["payload"]["base_url"] == "http://127.0.0.1:11435/v1" + assert edit_request["payload"]["default_model"] == "legacy-v2" + assert "api_key" not in edit_request["payload"] + + page.once("dialog", lambda browser_dialog: browser_dialog.accept()) + await legacy_card.get_by_role("button", name="Delete").click() + await expect(page.get_by_text("Provider deleted.")).to_be_visible(timeout=5000) + await expect(_provider_card(page, "legacy-local")).to_have_count(0) + assert { + "kind": "delete", + "payload": {"provider_id": "legacy-local"}, + } in llm_requests + finally: + await harness["context"].close() diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py new file mode 100644 index 00000000000..f93a8819e18 --- /dev/null +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py @@ -0,0 +1,247 @@ +"""Legacy Skills settings lifecycle coverage ported to Reborn WebChat v2.""" + +import asyncio +import json +from urllib.parse import unquote, urlparse + +from playwright.async_api import expect + +from helpers import REBORN_V2_AUTH_TOKEN +from reborn_webui_harness import ( + reborn_v2_browser, # noqa: F401 - imported fixture + reborn_v2_server, # noqa: F401 - imported fixture +) + + +MOCK_INSTALLED_SKILL = { + "name": "markdown-helper", + "description": "Deterministic E2E skill for markdown workflows.", + "version": "1.0.0", + "trust": "Installed", + "source_kind": "installed", + "keywords": ["markdown", "e2e"], + "usage_hint": "Type `/markdown-helper` in chat to force-activate this skill.", + "has_requirements": False, + "has_scripts": False, + "can_edit": True, + "can_delete": True, + "auto_activate": True, +} + +MOCK_SYSTEM_SKILL = { + "name": "system-helper", + "description": "Read-only system helper.", + "version": "1.0.0", + "trust": "Trusted", + "source_kind": "system", + "keywords": ["system"], + "has_requirements": False, + "has_scripts": False, + "can_edit": False, + "can_delete": False, +} + +MOCK_WORKSPACE_SKILL = { + "name": "workspace-helper", + "description": "Read-only workspace helper.", + "version": "1.0.0", + "trust": "Trusted", + "source_kind": "workspace", + "keywords": ["workspace"], + "has_requirements": False, + "has_scripts": False, + "can_edit": False, + "can_delete": False, +} + +MOCK_SKILL_CONTENT = ( + "---\n" + "name: markdown-helper\n" + "description: Deterministic E2E skill for markdown workflows.\n" + "---\n\n" + "# Markdown Helper\n" +) + + +async def _open_mocked_skills_page(reborn_v2_server, reborn_v2_browser, *, initial_skills=None): + context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) + page = await context.new_page() + installed = [dict(skill) for skill in (initial_skills or [])] + install_requests: list[dict] = [] + update_requests: list[dict] = [] + delete_requests: list[str] = [] + + async def fulfill_json(route, payload, status=200): + await route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + headers={"Cache-Control": "no-store"}, + ) + + async def handle_skills(route): + nonlocal installed + request = route.request + path = urlparse(request.url).path + + if path == "/api/webchat/v2/skills" and request.method == "GET": + await fulfill_json( + route, + { + "skills": installed, + "count": len(installed), + "auto_activate_learned": True, + }, + ) + return + + if path == "/api/webchat/v2/skills/install" and request.method == "POST": + payload = json.loads(request.post_data or "{}") + install_requests.append({"headers": request.headers, "body": payload}) + if not any(skill["name"] == payload.get("name") for skill in installed): + skill = dict(MOCK_INSTALLED_SKILL) + skill["name"] = payload.get("name", skill["name"]) + installed = [skill] + await fulfill_json( + route, + {"success": True, "message": f"Skill '{payload.get('name')}' installed"}, + ) + return + + if path.startswith("/api/webchat/v2/skills/") and request.method == "GET": + name = unquote(path.removeprefix("/api/webchat/v2/skills/")) + await fulfill_json(route, {"name": name, "content": MOCK_SKILL_CONTENT}) + return + + if path.startswith("/api/webchat/v2/skills/") and request.method == "PUT": + name = unquote(path.removeprefix("/api/webchat/v2/skills/")) + update_requests.append( + { + "name": name, + "headers": request.headers, + "body": json.loads(request.post_data or "{}"), + } + ) + await fulfill_json(route, {"success": True, "message": f"Skill '{name}' updated"}) + return + + if path.startswith("/api/webchat/v2/skills/") and request.method == "DELETE": + name = unquote(path.removeprefix("/api/webchat/v2/skills/")) + delete_requests.append(name) + installed = [skill for skill in installed if skill["name"] != name] + await fulfill_json(route, {"success": True, "message": f"Skill '{name}' removed"}) + return + + await route.continue_() + + await page.route("**/api/webchat/v2/skills**", handle_skills) + await page.goto(f"{reborn_v2_server}/v2/settings/skills?token={REBORN_V2_AUTH_TOKEN}") + await expect(page.get_by_text("Add skill")).to_be_visible(timeout=15000) + + return { + "context": context, + "page": page, + "install_requests": install_requests, + "update_requests": update_requests, + "delete_requests": delete_requests, + } + + +async def _add_mock_skill(page): + await page.get_by_placeholder("skill-name").fill("markdown-helper") + await page.get_by_placeholder("---\\nname: example\\ndescription: ...\\n---\\n").fill( + MOCK_SKILL_CONTENT + ) + await page.get_by_role("button", name="Add").click() + card = page.locator("#skills-list .ext-card").filter(has_text="markdown-helper") + await expect(card).to_be_visible(timeout=5000) + return card + + +async def test_reborn_legacy_skills_tab_visible(reborn_v2_server, reborn_v2_browser): + harness = await _open_mocked_skills_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + await expect(page.get_by_text("Add skill")).to_be_visible() + await expect(page.get_by_placeholder("skill-name")).to_be_visible() + await expect( + page.get_by_placeholder("---\\nname: example\\ndescription: ...\\n---\\n") + ).to_be_visible() + await expect(page.get_by_role("button", name="Default: On")).to_be_visible() + finally: + await harness["context"].close() + + +async def test_reborn_legacy_skills_add_edit_delete(reborn_v2_server, reborn_v2_browser): + harness = await _open_mocked_skills_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + card = await _add_mock_skill(page) + + assert len(harness["install_requests"]) == 1 + assert harness["install_requests"][0]["headers"].get("x-confirm-action") == "true" + assert harness["install_requests"][0]["body"] == { + "name": "markdown-helper", + "content": MOCK_SKILL_CONTENT.strip(), + } + + await card.get_by_role("button", name="Edit").click() + editor = card.locator("textarea") + await expect(editor).to_be_visible(timeout=5000) + await editor.fill( + "---\nname: markdown-helper\ndescription: Updated E2E skill\n---\n\n# Updated\n" + ) + await card.get_by_role("button", name="Save").click() + await expect(editor).to_be_hidden(timeout=5000) + + assert len(harness["update_requests"]) == 1 + update = harness["update_requests"][0] + assert update["name"] == "markdown-helper" + assert update["headers"].get("x-confirm-action") == "true" + assert "Updated E2E skill" in update["body"]["content"] + + loop = asyncio.get_running_loop() + dialog_future = loop.create_future() + + def handle_dialog(dialog): + if not dialog_future.done(): + dialog_future.set_result( + {"type": dialog.type, "message": dialog.message} + ) + loop.create_task(dialog.accept()) + + page.once("dialog", handle_dialog) + await card.get_by_role("button", name="Delete").click() + dialog = await asyncio.wait_for(dialog_future, timeout=5) + assert dialog["type"] == "confirm" + assert "markdown-helper" in dialog["message"] + + await expect( + page.locator("#skills-list .ext-card").filter(has_text="markdown-helper") + ).to_have_count(0, timeout=5000) + assert harness["delete_requests"] == ["markdown-helper"] + finally: + await harness["context"].close() + + +async def test_reborn_legacy_skills_read_only_sources_hide_edit_and_delete( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_skills_page( + reborn_v2_server, + reborn_v2_browser, + initial_skills=[MOCK_SYSTEM_SKILL, MOCK_WORKSPACE_SKILL], + ) + try: + page = harness["page"] + system_card = page.locator("#skills-list .ext-card").filter(has_text="system-helper") + workspace_card = page.locator("#skills-list .ext-card").filter(has_text="workspace-helper") + await expect(system_card).to_be_visible(timeout=5000) + await expect(workspace_card).to_be_visible(timeout=5000) + + for card in (system_card, workspace_card): + await expect(card.get_by_role("button", name="Edit")).to_have_count(0) + await expect(card.get_by_role("button", name="Delete")).to_have_count(0) + await expect(card.get_by_role("button", name="Auto-activate: On")).to_have_count(0) + finally: + await harness["context"].close() diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py new file mode 100644 index 00000000000..de2ba0d2386 --- /dev/null +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py @@ -0,0 +1,457 @@ +"""Legacy tool-permission coverage ported to Reborn WebChat v2.""" + +import asyncio +import json +from pathlib import Path +from urllib.parse import quote, unquote, urlparse + +import httpx +import pytest +from playwright.async_api import expect + +from helpers import REBORN_V2_AUTH_TOKEN +from reborn_webui_harness import ( + client_action_id, + create_thread, + reborn_v2_browser, # noqa: F401 - imported fixture + reborn_v2_restartable_server, # noqa: F401 - imported fixture + reborn_v2_server, # noqa: F401 - imported fixture + reborn_bearer_headers, + send_message, + wait_for_assistant_message, +) + + +def _tool_entry( + name: str, + *, + state: str, + default_state: str = "ask_each_time", + source: str = "override", + mutable: bool = True, + description: str | None = None, +) -> dict: + return { + "key": f"tool.{name}", + "value": { + "name": name, + "description": description or f"{name} deterministic test tool.", + "state": state, + "default_state": default_state, + "locked": not mutable, + "effective_source": source, + }, + "mutable": mutable, + "source": source, + } + + +async def _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser): + context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) + page = await context.new_page() + auto_approve = {"enabled": False} + tool_states = { + "echo": { + "state": "always_allow", + "default_state": "ask_each_time", + "source": "override", + "mutable": True, + "description": "Echo text back.", + }, + "tool.financial": { + "state": "ask_each_time", + "default_state": "ask_each_time", + "source": "locked", + "mutable": False, + "description": "Hard-floor approval tool.", + }, + } + auto_approve_requests: list[dict] = [] + permission_requests: list[dict] = [] + + def entries(): + return [ + { + "key": "agent.auto_approve_tools", + "value": auto_approve["enabled"], + "mutable": True, + "source": "override" if auto_approve["enabled"] else "default", + }, + *[ + _tool_entry( + name, + state=data["state"], + default_state=data["default_state"], + source=data["source"], + mutable=data["mutable"], + description=data["description"], + ) + for name, data in tool_states.items() + ], + ] + + async def fulfill_json(route, payload, status=200): + await route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + headers={"Cache-Control": "no-store"}, + ) + + async def handle_settings_tools(route): + request = route.request + path = urlparse(request.url).path + + if path == "/api/webchat/v2/settings/tools" and request.method == "GET": + await fulfill_json(route, {"entries": entries()}) + return + + if path == "/api/webchat/v2/settings/tools" and request.method == "POST": + body = json.loads(request.post_data or "{}") + auto_approve_requests.append(body) + auto_approve["enabled"] = bool(body.get("enabled")) + await fulfill_json( + route, + { + "entry": { + "key": "agent.auto_approve_tools", + "value": auto_approve["enabled"], + "mutable": True, + "source": "override", + } + }, + ) + return + + if ( + path.startswith("/api/webchat/v2/settings/tools/") + and request.method == "POST" + ): + name = unquote(path.removeprefix("/api/webchat/v2/settings/tools/")) + body = json.loads(request.post_data or "{}") + permission_requests.append({"name": name, "body": body}) + tool = tool_states[name] + if not tool["mutable"]: + await fulfill_json( + route, + {"kind": "bad_request", "message": "locked tool"}, + status=400, + ) + return + + requested = body.get("state") or "default" + if requested == "default": + tool["state"] = tool["default_state"] + tool["source"] = "default" + else: + tool["state"] = requested + tool["source"] = "override" + + await fulfill_json( + route, + { + "entry": _tool_entry( + name, + state=tool["state"], + default_state=tool["default_state"], + source=tool["source"], + mutable=tool["mutable"], + description=tool["description"], + ) + }, + ) + return + + await route.continue_() + + await page.route("**/api/webchat/v2/settings/tools**", handle_settings_tools) + await page.goto(f"{reborn_v2_server}/v2/settings/tools?token={REBORN_V2_AUTH_TOKEN}") + await expect(page.get_by_placeholder("Search settings...")).to_be_visible(timeout=15000) + await expect(page.get_by_text("Tool permissions")).to_be_visible(timeout=5000) + + return { + "context": context, + "page": page, + "auto_approve_requests": auto_approve_requests, + "permission_requests": permission_requests, + } + + +def _tool_row(page, name: str): + return page.locator(f'[data-testid="settings-tool-row"][data-tool-name="{name}"]') + + +@pytest.fixture +def reborn_approval_artifact_cleanup(): + yield + for label in ("first", "second"): + Path(f"reborn-approval-{label}.txt").unlink(missing_ok=True) + + +async def _set_real_auto_approve(reborn_v2_server: str, enabled: bool): + headers = {"Authorization": f"Bearer {REBORN_V2_AUTH_TOKEN}"} + async with httpx.AsyncClient(headers=headers) as client: + response = await client.post( + f"{reborn_v2_server}/api/webchat/v2/settings/tools", + json={"enabled": enabled}, + timeout=15, + ) + response.raise_for_status() + return response.json() + + +async def _get_real_tool_state( + client: httpx.AsyncClient, base_url: str, capability_id: str +) -> dict: + response = await client.get( + f"{base_url}/api/webchat/v2/settings/tools", + timeout=15, + ) + response.raise_for_status() + for entry in response.json().get("entries", []): + if entry.get("key") == f"tool.{capability_id}": + return entry + raise AssertionError(f"{capability_id} missing from Tools settings") + + +async def _wait_for_gate_prompt_after_send( + base_url: str, thread_id: str, content: str +) -> dict: + url = ( + f"{base_url}/api/webchat/v2/threads/{thread_id}/events" + f"?token={REBORN_V2_AUTH_TOKEN}" + ) + timeout = httpx.Timeout(60.0, read=60.0) + async with httpx.AsyncClient(timeout=timeout) as stream_client: + async with stream_client.stream("GET", url) as response: + response.raise_for_status() + async with httpx.AsyncClient(headers=reborn_bearer_headers()) as action_client: + await send_message(action_client, base_url, thread_id, content) + + event_name = None + data_lines: list[str] = [] + async with asyncio.timeout(45): + async for line in response.aiter_lines(): + if line.startswith(":"): + continue + if line.startswith("event:"): + event_name = line.removeprefix("event:").strip() + continue + if line.startswith("data:"): + data_lines.append(line.removeprefix("data:").lstrip()) + continue + if line == "": + if event_name == "gate" and data_lines: + frame = json.loads("\n".join(data_lines)) + return frame["prompt"] + event_name = None + data_lines = [] + raise AssertionError("SSE stream closed before a gate prompt arrived") + + +async def test_reborn_legacy_tool_permissions_tab_visible( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + await expect(page.get_by_text("Always allow eligible tools")).to_be_visible() + await expect(_tool_row(page, "echo")).to_be_visible(timeout=5000) + await expect(_tool_row(page, "tool.financial")).to_be_visible(timeout=5000) + finally: + await harness["context"].close() + + +async def test_reborn_legacy_tool_permission_select_persists_after_reload( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + select = page.get_by_label("Permission for echo") + await expect(select).to_have_value("always_allow", timeout=5000) + + await select.select_option("ask_each_time") + await expect(select).to_have_value("ask_each_time") + await expect(_tool_row(page, "echo").get_by_text("saved")).to_be_visible(timeout=5000) + assert harness["permission_requests"][-1] == { + "name": "echo", + "body": {"state": "ask_each_time"}, + } + + await page.reload() + await expect(page.get_by_placeholder("Search settings...")).to_be_visible(timeout=15000) + await expect(page.get_by_label("Permission for echo")).to_have_value( + "ask_each_time", + timeout=5000, + ) + + await page.get_by_label("Permission for echo").select_option("default") + await expect(page.get_by_label("Permission for echo")).to_have_value("default") + assert harness["permission_requests"][-1] == { + "name": "echo", + "body": {"state": "default"}, + } + finally: + await harness["context"].close() + + +async def test_reborn_legacy_locked_tool_shows_badge_without_select( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + locked = _tool_row(page, "tool.financial") + await expect(locked).to_be_visible(timeout=5000) + await expect(locked.locator('[data-testid="settings-tool-lock"]')).to_be_visible() + await expect(locked.get_by_label("Permission for tool.financial")).to_have_count(0) + await expect(locked.get_by_text("Ask each time")).to_be_visible() + finally: + await harness["context"].close() + + +async def test_reborn_legacy_auto_approve_switch_persists( + reborn_v2_server, reborn_v2_browser +): + harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser) + try: + page = harness["page"] + switch = page.get_by_role("switch", name="Always allow eligible tools") + await expect(switch).to_have_attribute("aria-checked", "false") + await switch.click() + await expect(switch).to_have_attribute("aria-checked", "true") + assert harness["auto_approve_requests"] == [{"enabled": True}] + finally: + await harness["context"].close() + + +async def test_reborn_legacy_auto_approve_real_api_persists_across_browser_contexts( + reborn_v2_server, + reborn_v2_browser, +): + await _set_real_auto_approve(reborn_v2_server, False) + context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) + page = await context.new_page() + + try: + update = await _set_real_auto_approve(reborn_v2_server, True) + assert update["entry"]["key"] == "agent.auto_approve_tools" + assert update["entry"]["value"] is True + + await page.goto(f"{reborn_v2_server}/v2/settings/tools?token={REBORN_V2_AUTH_TOKEN}") + await expect(page.get_by_placeholder("Search settings...")).to_be_visible(timeout=15000) + switch = page.get_by_role("switch", name="Always allow eligible tools") + await expect(switch).to_have_attribute("aria-checked", "true", timeout=5000) + finally: + await context.close() + await _set_real_auto_approve(reborn_v2_server, False) + + +async def test_reborn_legacy_tool_permission_real_api_persists_and_rejects_locked( + reborn_v2_server, +): + headers = {"Authorization": f"Bearer {REBORN_V2_AUTH_TOKEN}"} + async with httpx.AsyncClient(headers=headers) as client: + response = await client.get( + f"{reborn_v2_server}/api/webchat/v2/settings/tools", + timeout=15, + ) + response.raise_for_status() + entries = response.json().get("entries", []) + + tools = [entry for entry in entries if entry.get("key", "").startswith("tool.")] + mutable = next((entry for entry in tools if entry.get("mutable") is not False), None) + locked = next((entry for entry in tools if entry.get("mutable") is False), None) + + if mutable is None: + pytest.skip("Reborn test catalog has no mutable operator tool") + + capability_id = mutable["key"].removeprefix("tool.") + update = await client.post( + f"{reborn_v2_server}/api/webchat/v2/settings/tools/{capability_id}", + json={"state": "disabled"}, + timeout=15, + ) + update.raise_for_status() + assert update.json()["entry"]["value"]["state"] == "disabled" + assert update.json()["entry"]["mutable"] is True + + reset = await client.post( + f"{reborn_v2_server}/api/webchat/v2/settings/tools/{capability_id}", + json={"state": "default"}, + timeout=15, + ) + reset.raise_for_status() + assert reset.json()["entry"]["key"] == mutable["key"] + + if locked is not None: + locked_id = locked["key"].removeprefix("tool.") + rejected = await client.post( + f"{reborn_v2_server}/api/webchat/v2/settings/tools/{locked_id}", + json={"state": "always_allow"}, + timeout=15, + ) + assert rejected.status_code >= 400 + + +async def test_reborn_legacy_always_approve_survives_reborn_restart( + reborn_v2_restartable_server, + reborn_approval_artifact_cleanup, +): + state, start_server, stop_server = reborn_v2_restartable_server + capability_id = "builtin.write_file" + + async with httpx.AsyncClient(headers=reborn_bearer_headers()) as client: + base_url = state["base_url"] + reset = await client.post( + f"{base_url}/api/webchat/v2/settings/tools/{capability_id}", + json={"state": "default"}, + timeout=15, + ) + reset.raise_for_status() + thread_id = await create_thread(client, base_url) + + first_prompt = await _wait_for_gate_prompt_after_send( + state["base_url"], + thread_id, + "reborn write approval file first", + ) + assert first_prompt["allow_always"] is True + assert first_prompt["approval_context"]["tool_name"] == capability_id + + async with httpx.AsyncClient(headers=reborn_bearer_headers()) as client: + gate_ref = quote(first_prompt["gate_ref"], safe="") + resolve = await client.post( + ( + f"{state['base_url']}/api/webchat/v2/threads/{thread_id}/runs/" + f"{first_prompt['turn_run_id']}/gates/{gate_ref}/resolve" + ), + json={ + "client_action_id": client_action_id(), + "resolution": "approved", + "always": True, + }, + timeout=15, + ) + resolve.raise_for_status() + await wait_for_assistant_message(client, state["base_url"], thread_id) + + persisted = await _get_real_tool_state(client, state["base_url"], capability_id) + assert persisted["value"]["state"] == "always_allow" + + await stop_server() + restarted_url = await start_server() + + async with httpx.AsyncClient(headers=reborn_bearer_headers()) as client: + restarted = await _get_real_tool_state(client, restarted_url, capability_id) + assert restarted["value"]["state"] == "always_allow" + + second_thread_id = await create_thread(client, restarted_url) + await send_message( + client, + restarted_url, + second_thread_id, + "reborn write approval file second", + ) + await wait_for_assistant_message(client, restarted_url, second_thread_id) From dbf31b18af5d9718d0ae3ae137ab742a4a8fe55a Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Tue, 30 Jun 2026 14:03:53 +0300 Subject: [PATCH 2/2] fix(webui-v2): address review feedback --- .../static/js/pages/projects/projects-page.js | 1 + .../js/pages/settings/hooks/useSettings.js | 3 +- .../static/js/pages/settings/settings-page.js | 10 + tests/e2e/helpers.py | 12 + .../test_reborn_webui_v2_legacy_projects.py | 292 +++++++++--------- ..._reborn_webui_v2_legacy_settings_search.py | 8 +- .../test_reborn_webui_v2_legacy_skills.py | 34 +- ...reborn_webui_v2_legacy_tool_permissions.py | 26 +- 8 files changed, 220 insertions(+), 166 deletions(-) diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js index d52f2457d0a..ea62d64c4c9 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js @@ -64,6 +64,7 @@ export function ProjectsPage() { type: "error", message: error.message || t("projects.chatAutoFail"), }); + return; } navigate(nextThreadId ? `/chat/${nextThreadId}` : "/chat", { diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useSettings.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useSettings.js index 40f43831d41..254ed7784f9 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useSettings.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useSettings.js @@ -83,6 +83,7 @@ export function useSettings() { needsRestart, importSettings, isImporting: importMutation.isPending, - saveError: mutation.error || importMutation.error, + saveError: mutation.error, + importError: importMutation.error, }; } diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/settings-page.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/settings-page.js index 223d1c84672..6ac00aa1fec 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/settings-page.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/settings-page.js @@ -29,6 +29,7 @@ export function SettingsPage() { importSettings, isImporting, saveError, + importError, } = useSettings(); const [searchQuery, setSearchQuery] = React.useState(""); @@ -108,6 +109,15 @@ export function SettingsPage() {
`} + ${importError && + html` +
+ ${t("settings.importFailed", { message: importError.message })} +
+ `} + <${SettingsToolbar} settingsExport=${query.data || null} onImport=${importSettings} diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index cef7e82f9e5..f630aa4c931 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -306,6 +306,18 @@ "logs_entry_message": "[data-testid='logs-entry-message']", "logs_entry_context": "[data-testid='logs-entry-context']", "logs_context_chip": "[data-testid='logs-context-chip'][data-context-key='{key}']", + "settings_search_placeholder": "Search settings...", + "settings_tool_row_for": ( + "[data-testid='settings-tool-row'][data-tool-name='{name}']" + ), + "settings_tool_lock": "[data-testid='settings-tool-lock']", + "llm_provider_card_for": ( + "[data-testid='llm-provider-card'][data-provider-id='{provider_id}']" + ), + "llm_provider_disclosure": "llm-provider-disclosure", + "skills_card": "#skills-list .ext-card", + "skill_name_placeholder": "skill-name", + "skill_content_placeholder": "---\\nname: example\\ndescription: ...\\n---\\n", } diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py index b5ce7bc091b..15341312792 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py @@ -64,174 +64,184 @@ async def _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser): - context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) - page = await context.new_page() - project_requests: list[str] = [] - thread_create_requests: list[dict] = [] - project_thread_requests: list[str] = [] - project_file_requests: list[str] = [] - - async def fulfill_json(route, payload, status=200): - await route.fulfill( - status=status, - content_type="application/json", - body=json.dumps(payload), - headers={"Cache-Control": "no-store"}, - ) + context = await reborn_v2_browser.new_context( + viewport={"width": 1280, "height": 720} + ) + try: + page = await context.new_page() + project_requests: list[str] = [] + thread_create_requests: list[dict] = [] + project_thread_requests: list[str] = [] + project_file_requests: list[str] = [] - async def handle_projects(route): - request = route.request - parsed = urlparse(request.url) - path = parsed.path - - if path == "/api/webchat/v2/projects" and request.method == "GET": - project_requests.append(path) - await fulfill_json(route, {"projects": MOCK_PROJECTS}) - return - - prefix = "/api/webchat/v2/projects/" - if path.startswith(prefix) and request.method == "GET": - project_id = unquote(path.removeprefix(prefix)) - project_requests.append(path) - project = next( - ( - candidate - for candidate in MOCK_PROJECTS - if candidate["project_id"] == project_id - ), - None, - ) - await fulfill_json( - route, - {"project": project}, - status=200 if project is not None else 404, + async def fulfill_json(route, payload, status=200): + await route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + headers={"Cache-Control": "no-store"}, ) - return - await route.continue_() + async def handle_projects(route): + request = route.request + parsed = urlparse(request.url) + path = parsed.path + + if path == "/api/webchat/v2/projects" and request.method == "GET": + project_requests.append(path) + await fulfill_json(route, {"projects": MOCK_PROJECTS}) + return + + prefix = "/api/webchat/v2/projects/" + if path.startswith(prefix) and request.method == "GET": + project_id = unquote(path.removeprefix(prefix)) + project_requests.append(path) + project = next( + ( + candidate + for candidate in MOCK_PROJECTS + if candidate["project_id"] == project_id + ), + None, + ) + await fulfill_json( + route, + {"project": project}, + status=200 if project is not None else 404, + ) + return + + await route.continue_() + + async def handle_threads(route): + request = route.request + parsed = urlparse(request.url) + path = parsed.path - async def handle_threads(route): - request = route.request - parsed = urlparse(request.url) - path = parsed.path + if path == "/api/webchat/v2/threads" and request.method == "GET": + query = parse_qs(parsed.query) + if query.get("project_id") == [MOCK_PROJECT_ID]: + project_thread_requests.append(request.url) + await fulfill_json( + route, + { + "threads": [ + { + "thread_id": PROJECT_THREAD_ID, + "title": "Weekly research digest", + "goal": "Summarize launch-readiness signals.", + "thread_type": "chat", + "project_id": MOCK_PROJECT_ID, + "created_at": "2026-04-12T11:30:00Z", + "updated_at": "2026-04-12T12:00:00Z", + } + ], + "next_cursor": None, + }, + ) + return + await fulfill_json(route, {"threads": [], "next_cursor": None}) + return - if path == "/api/webchat/v2/threads" and request.method == "GET": - query = parse_qs(parsed.query) - if query.get("project_id") == [MOCK_PROJECT_ID]: - project_thread_requests.append(request.url) + if path == "/api/webchat/v2/threads" and request.method == "POST": + body = json.loads(request.post_data or "{}") + thread_create_requests.append(body) await fulfill_json( route, { - "threads": [ - { - "thread_id": PROJECT_THREAD_ID, - "title": "Weekly research digest", - "goal": "Summarize launch-readiness signals.", - "thread_type": "chat", - "project_id": MOCK_PROJECT_ID, - "created_at": "2026-04-12T11:30:00Z", - "updated_at": "2026-04-12T12:00:00Z", - } - ], - "next_cursor": None, + "thread": { + "thread_id": "thread-project-scoped", + "title": "Project scoped conversation", + "project_id": body.get("project_id"), + "created_at": "2026-04-12T11:00:00Z", + "updated_at": "2026-04-12T11:00:00Z", + } }, ) return - await fulfill_json(route, {"threads": [], "next_cursor": None}) - return - - if path == "/api/webchat/v2/threads" and request.method == "POST": - body = json.loads(request.post_data or "{}") - thread_create_requests.append(body) - await fulfill_json( - route, - { - "thread": { - "thread_id": "thread-project-scoped", - "title": "Project scoped conversation", - "project_id": body.get("project_id"), - "created_at": "2026-04-12T11:00:00Z", - "updated_at": "2026-04-12T11:00:00Z", - } - }, - ) - return - if path == "/api/webchat/v2/threads/thread-project-scoped/timeline": - await fulfill_json(route, {"messages": [], "next_cursor": None}) - return + if path == "/api/webchat/v2/threads/thread-project-scoped/timeline": + await fulfill_json(route, {"messages": [], "next_cursor": None}) + return + + if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files": + project_file_requests.append(request.url) + query = parse_qs(parsed.query) + if query.get("path") == ["/workspace/reports"]: + await fulfill_json( + route, + { + "entries": [ + { + "name": "launch-brief.md", + "path": "/workspace/reports/launch-brief.md", + "kind": "file", + "size": len(PROJECT_WORKSPACE_FILE_BYTES), + } + ] + }, + ) + return - if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files": - project_file_requests.append(request.url) - query = parse_qs(parsed.query) - if query.get("path") == ["/workspace/reports"]: await fulfill_json( route, { "entries": [ { - "name": "launch-brief.md", - "path": "/workspace/reports/launch-brief.md", + "name": "reports", + "path": "/workspace/reports", + "kind": "directory", + }, + { + "name": "README.md", + "path": "/workspace/README.md", "kind": "file", - "size": len(PROJECT_WORKSPACE_FILE_BYTES), - } + "size": 42, + }, ] }, ) return - await fulfill_json( - route, - { - "entries": [ - { - "name": "reports", - "path": "/workspace/reports", - "kind": "directory", - }, - { - "name": "README.md", - "path": "/workspace/README.md", - "kind": "file", - "size": 42, - }, - ] - }, - ) - return - - if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files/content": - project_file_requests.append(request.url) - await route.fulfill( - status=200, - content_type="text/markdown", - body=PROJECT_WORKSPACE_FILE_BYTES.decode("utf-8"), - headers={"Cache-Control": "no-store"}, - ) - return + if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files/content": + project_file_requests.append(request.url) + await route.fulfill( + status=200, + content_type="text/markdown", + body=PROJECT_WORKSPACE_FILE_BYTES.decode("utf-8"), + headers={"Cache-Control": "no-store"}, + ) + return - await route.continue_() + await route.continue_() - await page.route("**/api/webchat/v2/projects**", handle_projects) - await page.route("**/api/webchat/v2/threads**", handle_threads) - await page.goto(f"{reborn_v2_server}/v2/projects?token={REBORN_V2_AUTH_TOKEN}") + await page.route("**/api/webchat/v2/projects**", handle_projects) + await page.route("**/api/webchat/v2/threads**", handle_threads) + await page.goto( + f"{reborn_v2_server}/v2/projects?token={REBORN_V2_AUTH_TOKEN}" + ) - try: - await expect(page.locator(SEL_V2["projects_grid"])).to_be_visible(timeout=15000) - except AssertionError as error: - body_text = await page.locator("body").inner_text(timeout=1000) - raise AssertionError( - f"Projects grid did not render on {page.url}.\nBody text:\n{body_text}" - ) from error - - return { - "context": context, - "page": page, - "project_requests": project_requests, - "thread_create_requests": thread_create_requests, - "project_thread_requests": project_thread_requests, - "project_file_requests": project_file_requests, - } + try: + await expect(page.locator(SEL_V2["projects_grid"])).to_be_visible( + timeout=15000 + ) + except AssertionError as error: + body_text = await page.locator("body").inner_text(timeout=1000) + raise AssertionError( + f"Projects grid did not render on {page.url}.\nBody text:\n{body_text}" + ) from error + + return { + "context": context, + "page": page, + "project_requests": project_requests, + "thread_create_requests": thread_create_requests, + "project_thread_requests": project_thread_requests, + "project_file_requests": project_file_requests, + } + except Exception: + await context.close() + raise async def test_reborn_legacy_projects_overview_search_and_open_workspace( diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py index fe07db2ddfa..a1982bdbf33 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py @@ -5,7 +5,7 @@ from playwright.async_api import expect -from helpers import REBORN_V2_AUTH_TOKEN +from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2 from reborn_webui_harness import ( reborn_v2_browser, # noqa: F401 - imported fixture reborn_v2_server, # noqa: F401 - imported fixture @@ -304,7 +304,7 @@ def provider_from_payload(payload: dict) -> dict: await page.route("**/api/webchat/v2/llm/**", handle_llm) await page.goto(f"{reborn_v2_server}/v2/settings/{tab}?token={REBORN_V2_AUTH_TOKEN}") - search = page.get_by_placeholder("Search settings...") + search = page.get_by_placeholder(SEL_V2["settings_search_placeholder"]) try: await expect(search).to_be_visible(timeout=15000) except AssertionError as error: @@ -320,7 +320,7 @@ def provider_from_payload(payload: dict) -> dict: def _provider_card(page, provider_id: str): return page.locator( - f"[data-testid='llm-provider-card'][data-provider-id='{provider_id}']" + SEL_V2["llm_provider_card_for"].format(provider_id=provider_id) ) @@ -567,7 +567,7 @@ async def test_reborn_legacy_settings_inference_edit_and_delete_custom_provider( legacy_card = _provider_card(page, "legacy-local") await expect(legacy_card).to_be_visible(timeout=5000) - await legacy_card.get_by_test_id("llm-provider-disclosure").click() + await legacy_card.get_by_test_id(SEL_V2["llm_provider_disclosure"]).click() await legacy_card.get_by_role("button", name="Edit").click() dialog = page.get_by_role("dialog") await expect( diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py index f93a8819e18..16c3797f3d7 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py @@ -6,7 +6,7 @@ from playwright.async_api import expect -from helpers import REBORN_V2_AUTH_TOKEN +from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2 from reborn_webui_harness import ( reborn_v2_browser, # noqa: F401 - imported fixture reborn_v2_server, # noqa: F401 - imported fixture @@ -148,12 +148,14 @@ async def handle_skills(route): async def _add_mock_skill(page): - await page.get_by_placeholder("skill-name").fill("markdown-helper") - await page.get_by_placeholder("---\\nname: example\\ndescription: ...\\n---\\n").fill( + await page.get_by_placeholder(SEL_V2["skill_name_placeholder"]).fill( + "markdown-helper" + ) + await page.get_by_placeholder(SEL_V2["skill_content_placeholder"]).fill( MOCK_SKILL_CONTENT ) await page.get_by_role("button", name="Add").click() - card = page.locator("#skills-list .ext-card").filter(has_text="markdown-helper") + card = page.locator(SEL_V2["skills_card"]).filter(has_text="markdown-helper") await expect(card).to_be_visible(timeout=5000) return card @@ -163,9 +165,11 @@ async def test_reborn_legacy_skills_tab_visible(reborn_v2_server, reborn_v2_brow try: page = harness["page"] await expect(page.get_by_text("Add skill")).to_be_visible() - await expect(page.get_by_placeholder("skill-name")).to_be_visible() await expect( - page.get_by_placeholder("---\\nname: example\\ndescription: ...\\n---\\n") + page.get_by_placeholder(SEL_V2["skill_name_placeholder"]) + ).to_be_visible() + await expect( + page.get_by_placeholder(SEL_V2["skill_content_placeholder"]) ).to_be_visible() await expect(page.get_by_role("button", name="Default: On")).to_be_visible() finally: @@ -203,12 +207,12 @@ async def test_reborn_legacy_skills_add_edit_delete(reborn_v2_server, reborn_v2_ loop = asyncio.get_running_loop() dialog_future = loop.create_future() - def handle_dialog(dialog): + async def handle_dialog(dialog): if not dialog_future.done(): dialog_future.set_result( {"type": dialog.type, "message": dialog.message} ) - loop.create_task(dialog.accept()) + await dialog.accept() page.once("dialog", handle_dialog) await card.get_by_role("button", name="Delete").click() @@ -217,7 +221,7 @@ def handle_dialog(dialog): assert "markdown-helper" in dialog["message"] await expect( - page.locator("#skills-list .ext-card").filter(has_text="markdown-helper") + page.locator(SEL_V2["skills_card"]).filter(has_text="markdown-helper") ).to_have_count(0, timeout=5000) assert harness["delete_requests"] == ["markdown-helper"] finally: @@ -234,14 +238,20 @@ async def test_reborn_legacy_skills_read_only_sources_hide_edit_and_delete( ) try: page = harness["page"] - system_card = page.locator("#skills-list .ext-card").filter(has_text="system-helper") - workspace_card = page.locator("#skills-list .ext-card").filter(has_text="workspace-helper") + system_card = page.locator(SEL_V2["skills_card"]).filter( + has_text="system-helper" + ) + workspace_card = page.locator(SEL_V2["skills_card"]).filter( + has_text="workspace-helper" + ) await expect(system_card).to_be_visible(timeout=5000) await expect(workspace_card).to_be_visible(timeout=5000) for card in (system_card, workspace_card): await expect(card.get_by_role("button", name="Edit")).to_have_count(0) await expect(card.get_by_role("button", name="Delete")).to_have_count(0) - await expect(card.get_by_role("button", name="Auto-activate: On")).to_have_count(0) + await expect( + card.get_by_role("button", name="Auto-activate: On") + ).to_have_count(0) finally: await harness["context"].close() diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py index de2ba0d2386..b2112e8d7da 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py @@ -9,7 +9,7 @@ import pytest from playwright.async_api import expect -from helpers import REBORN_V2_AUTH_TOKEN +from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2 from reborn_webui_harness import ( client_action_id, create_thread, @@ -166,7 +166,9 @@ async def handle_settings_tools(route): await page.route("**/api/webchat/v2/settings/tools**", handle_settings_tools) await page.goto(f"{reborn_v2_server}/v2/settings/tools?token={REBORN_V2_AUTH_TOKEN}") - await expect(page.get_by_placeholder("Search settings...")).to_be_visible(timeout=15000) + await expect( + page.get_by_placeholder(SEL_V2["settings_search_placeholder"]) + ).to_be_visible(timeout=15000) await expect(page.get_by_text("Tool permissions")).to_be_visible(timeout=5000) return { @@ -178,7 +180,7 @@ async def handle_settings_tools(route): def _tool_row(page, name: str): - return page.locator(f'[data-testid="settings-tool-row"][data-tool-name="{name}"]') + return page.locator(SEL_V2["settings_tool_row_for"].format(name=name)) @pytest.fixture @@ -280,7 +282,9 @@ async def test_reborn_legacy_tool_permission_select_persists_after_reload( } await page.reload() - await expect(page.get_by_placeholder("Search settings...")).to_be_visible(timeout=15000) + await expect( + page.get_by_placeholder(SEL_V2["settings_search_placeholder"]) + ).to_be_visible(timeout=15000) await expect(page.get_by_label("Permission for echo")).to_have_value( "ask_each_time", timeout=5000, @@ -304,8 +308,10 @@ async def test_reborn_legacy_locked_tool_shows_badge_without_select( page = harness["page"] locked = _tool_row(page, "tool.financial") await expect(locked).to_be_visible(timeout=5000) - await expect(locked.locator('[data-testid="settings-tool-lock"]')).to_be_visible() - await expect(locked.get_by_label("Permission for tool.financial")).to_have_count(0) + await expect(locked.locator(SEL_V2["settings_tool_lock"])).to_be_visible() + await expect( + locked.get_by_label("Permission for tool.financial") + ).to_have_count(0) await expect(locked.get_by_text("Ask each time")).to_be_visible() finally: await harness["context"].close() @@ -339,8 +345,12 @@ async def test_reborn_legacy_auto_approve_real_api_persists_across_browser_conte assert update["entry"]["key"] == "agent.auto_approve_tools" assert update["entry"]["value"] is True - await page.goto(f"{reborn_v2_server}/v2/settings/tools?token={REBORN_V2_AUTH_TOKEN}") - await expect(page.get_by_placeholder("Search settings...")).to_be_visible(timeout=15000) + await page.goto( + f"{reborn_v2_server}/v2/settings/tools?token={REBORN_V2_AUTH_TOKEN}" + ) + await expect( + page.get_by_placeholder(SEL_V2["settings_search_placeholder"]) + ).to_be_visible(timeout=15000) switch = page.get_by_role("switch", name="Always allow eligible tools") await expect(switch).to_have_attribute("aria-checked", "true", timeout=5000) finally: