diff --git a/crates/ironclaw_webui_v2/frontend/src/design-system/select-menu.test.ts b/crates/ironclaw_webui_v2/frontend/src/design-system/select-menu.test.ts new file mode 100644 index 00000000000..8f066b0a463 --- /dev/null +++ b/crates/ironclaw_webui_v2/frontend/src/design-system/select-menu.test.ts @@ -0,0 +1,526 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { runVmModuleForTest } from "../test-support/vm-module-harness.ts"; + +function html(strings, ...values) { + return { strings: Array.from(strings), values }; +} + +function cn(...classes) { + return classes.flat().filter(Boolean).join(" "); +} + +function Icon() {} + +function visit(node, fn) { + if (Array.isArray(node)) { + for (const item of node) visit(item, fn); + return; + } + if (!node || typeof node !== "object") return; + fn(node); + if (Array.isArray(node.values)) { + for (const value of node.values) visit(value, fn); + } +} + +function collectScalars(root) { + const scalars = []; + visit(root, (node) => { + if (!Array.isArray(node.values)) return; + for (const value of node.values) { + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + scalars.push(value); + } + } + }); + return scalars; +} + +function collectObjects(root) { + const objects = []; + visit(root, (node) => { + if ( + node.props && + typeof node.props === "object" && + !Array.isArray(node.props) + ) { + objects.push(node.props); + } + if (!Array.isArray(node.values)) return; + for (const value of node.values) { + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + !Array.isArray(value.strings) + ) { + objects.push(value); + } + } + }); + return objects; +} + +function collectTemplateText(root) { + const text = []; + visit(root, (node) => { + if (Array.isArray(node.strings)) text.push(...node.strings); + }); + return text.join(""); +} + +function valuesAfter(root, fragment) { + const values = []; + visit(root, (node) => { + if (!Array.isArray(node.strings) || !Array.isArray(node.values)) return; + node.strings.forEach((part, index) => { + if (part.includes(fragment)) values.push(node.values[index]); + }); + }); + return values; +} + +function firstValueAfter(root, fragment) { + const [value] = valuesAfter(root, fragment); + assert.notEqual(value, undefined, `expected template fragment ${fragment}`); + return value; +} + +function firstObjectWith(root, key) { + const value = collectObjects(root).find((candidate) => key in candidate); + assert.notEqual(value, undefined, `expected object with ${key}`); + return value; +} + +function dependenciesChanged(previous, next) { + if (!previous || !next) return true; + if (previous.length !== next.length) return true; + return next.some((value, index) => !Object.is(value, previous[index])); +} + +function keyEvent(key) { + return { + key, + preventDefaultCalls: 0, + preventDefault() { + this.preventDefaultCalls += 1; + }, + }; +} + +function optionClasses(root) { + return collectScalars(root).filter( + (value) => typeof value === "string" && value.includes("flex w-full items-center") + ); +} + +const defaultOptions = [ + { value: "default", label: "Follow global", tone: "neutral" }, + { value: "always_allow", label: "Always allow", tone: "positive" }, + { value: "ask_each_time", label: "Ask each time", tone: "warning" }, + { value: "disabled", label: "Disabled", tone: "danger" }, +]; + +function createHarness() { + const state = { + effectCursor: 0, + effects: [], + hookCursor: 0, + hooks: [], + listenerAdds: 0, + listenerRemoves: 0, + listeners: {}, + refCursor: 0, + refs: [], + }; + const context = { + React: { + useEffect(effect, dependencies) { + const index = state.effectCursor; + state.effectCursor += 1; + const nextDependencies = dependencies ? Array.from(dependencies) : null; + const previous = state.effects[index]; + if ( + previous && + nextDependencies && + !dependenciesChanged(previous.dependencies, nextDependencies) + ) { + return undefined; + } + if (previous?.cleanup) previous.cleanup(); + const cleanup = effect(); + state.effects[index] = { + dependencies: nextDependencies, + cleanup: typeof cleanup === "function" ? cleanup : null, + }; + return undefined; + }, + useRef(initialValue) { + const index = state.refCursor; + state.refCursor += 1; + if (!state.refs[index]) state.refs[index] = { current: initialValue }; + return state.refs[index]; + }, + useState(initialValue) { + const index = state.hookCursor; + state.hookCursor += 1; + if (!(index in state.hooks)) { + state.hooks[index] = + typeof initialValue === "function" ? initialValue() : initialValue; + } + return [ + state.hooks[index], + (nextValue) => { + state.hooks[index] = + typeof nextValue === "function" ? nextValue(state.hooks[index]) : nextValue; + }, + ]; + }, + }, + document: { + addEventListener(type, listener) { + state.listenerAdds += 1; + state.listeners[type] = listener; + }, + removeEventListener(type, listener) { + state.listenerRemoves += 1; + if (state.listeners[type] === listener) delete state.listeners[type]; + }, + }, + html, + cn, + Icon, + }; + const exports = runVmModuleForTest( + "./select-menu.tsx", + ["SelectMenu"], + context, + import.meta.url + ); + return { + state, + SelectMenu: exports.SelectMenu, + render(props = {}) { + state.effectCursor = 0; + state.hookCursor = 0; + state.refCursor = 0; + return exports.SelectMenu({ + value: "default", + options: defaultOptions, + "aria-label": "Tool permission", + ...props, + }); + }, + renderPair(firstProps = {}, secondProps = {}) { + state.effectCursor = 0; + state.hookCursor = 0; + state.refCursor = 0; + return [ + exports.SelectMenu({ + value: "default", + options: defaultOptions, + "aria-label": "First permission", + ...firstProps, + }), + exports.SelectMenu({ + value: "default", + options: defaultOptions, + "aria-label": "Second permission", + ...secondProps, + }), + ]; + }, + unmount() { + for (const effect of state.effects) { + if (effect?.cleanup) effect.cleanup(); + } + state.effects = []; + state.effectCursor = 0; + }, + }; +} + +test("SelectMenu renders a closed custom trigger with the selected label", () => { + const harness = createHarness(); + const rendered = harness.render(); + + assert.match(collectTemplateText(rendered), /aria-haspopup="listbox"/); + assert.equal(firstValueAfter(rendered, "aria-expanded="), "false"); + assert.equal(collectObjects(rendered).some((value) => "aria-owns" in value), false); + assert.equal(collectObjects(rendered).some((value) => "aria-controls" in value), false); + assert.equal( + collectObjects(rendered).some((value) => "aria-activedescendant" in value), + false + ); + assert.ok(collectScalars(rendered).includes("Follow global")); + assert.doesNotMatch(collectTemplateText(rendered), / onPermissionChange(tool.name, e.currentTarget.value)} - aria-label={t("tools.permissionFor", { name: tool.name })} - className="v2-select h-8 rounded-md border border-[var(--v2-panel-border)] bg-[var(--v2-surface-soft)] px-2.5 font-mono text-xs text-[var(--v2-text-strong)] outline-none focus:border-[color-mix(in_srgb,var(--v2-accent)_45%,var(--v2-panel-border))]" - > - {permissionStates.map( - (p) => - () - )} - + options={permissionStates} + onChange={(value) => onPermissionChange(tool.name, value)} + ariaLabel={t("tools.permissionFor", { name: tool.name })} + className="w-36 sm:w-44" + data-testid="settings-tool-permission-select" + /> )} {isSaved && ( diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 18d519d52a8..704c947f76c 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -311,6 +311,9 @@ "settings_tool_row_for": ( "[data-testid='settings-tool-row'][data-tool-name='{name}']" ), + "settings_tool_permission": ( + "[data-testid='settings-tool-permission-select'] button[aria-haspopup='listbox']" + ), "settings_tool_lock": "[data-testid='settings-tool-lock']", "llm_provider_card_for": ( "[data-testid='llm-provider-card'][data-provider-id='{provider_id}']" diff --git a/tests/e2e/reborn_coverage_tests.txt b/tests/e2e/reborn_coverage_tests.txt index 633c7813dc8..5c4ad9cae10 100644 --- a/tests/e2e/reborn_coverage_tests.txt +++ b/tests/e2e/reborn_coverage_tests.txt @@ -14,6 +14,7 @@ # provider-fixture or model-tool-choice coverage that should stay in the normal # E2E suite but not in this Reborn binary coverage gate. tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py::test_reborn_legacy_tool_permission_menu_persists_after_reload tests/e2e/scenarios/test_reborn_v2_file_download.py tests/e2e/scenarios/test_reborn_responses_api.py::test_reborn_responses_lookup_and_cancel_missing_id_match_not_found_shape tests/e2e/scenarios/test_reborn_responses_api.py::test_reborn_openai_compat_route_mounts_require_bearer_served 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 b150e7b611d..f3dd286dc5b 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 @@ -193,6 +193,21 @@ def _tool_row(page, name: str): return page.locator(SEL_V2["settings_tool_row_for"].format(name=name)) +def _permission_button(page, name: str): + return _tool_row(page, name).locator(SEL_V2["settings_tool_permission"]) + + +async def _choose_permission(page, name: str, label: str): + button = _permission_button(page, name) + await button.click() + listbox_id = await button.get_attribute("aria-controls") + assert listbox_id + listbox = page.locator(f"#{listbox_id}") + await expect(listbox).to_be_visible(timeout=5000) + await listbox.get_by_role("option", name=label).click() + return button + + @pytest.fixture def reborn_approval_artifact_cleanup(): yield @@ -274,17 +289,20 @@ async def test_reborn_legacy_tool_permissions_tab_visible( await harness["context"].close() -async def test_reborn_legacy_tool_permission_select_persists_after_reload( +async def test_reborn_legacy_tool_permission_menu_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) + row = _tool_row(page, "echo") + await expect(row.locator("select")).to_have_count(0) + + button = _permission_button(page, "echo") + await expect(button).to_contain_text("Always allow", timeout=5000) - await select.select_option("ask_each_time") - await expect(select).to_have_value("ask_each_time") + await _choose_permission(page, "echo", "Ask each time") + await expect(button).to_contain_text("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", @@ -295,13 +313,13 @@ async def test_reborn_legacy_tool_permission_select_persists_after_reload( 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", + await expect(_permission_button(page, "echo")).to_contain_text( + "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") + await _choose_permission(page, "echo", "Follow global") + await expect(_permission_button(page, "echo")).to_contain_text("Follow global") assert harness["permission_requests"][-1] == { "name": "echo", "body": {"state": "default"}, @@ -320,16 +338,16 @@ async def test_reborn_legacy_tool_permission_save_failure_shows_error( ) try: page = harness["page"] - select = page.get_by_label("Permission for echo") - await expect(select).to_have_value("always_allow", timeout=5000) + button = _permission_button(page, "echo") + await expect(button).to_contain_text("Always allow", timeout=5000) - await select.select_option("ask_each_time") + await _choose_permission(page, "echo", "Ask each time") await expect(page.get_by_role("alert")).to_contain_text( "Save failed: Permission denied", timeout=5000, ) - await expect(select).to_have_value("always_allow", timeout=5000) + await expect(button).to_contain_text("Always allow", timeout=5000) assert harness["permission_requests"][-1] == { "name": "echo", "body": {"state": "ask_each_time"}, @@ -347,9 +365,8 @@ async def test_reborn_legacy_locked_tool_shows_badge_without_select( locked = _tool_row(page, "tool.financial") await expect(locked).to_be_visible(timeout=5000) 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.locator(SEL_V2["settings_tool_permission"])).to_have_count(0) + await expect(locked.locator("select")).to_have_count(0) await expect(locked.get_by_text("Ask each time")).to_be_visible() finally: await harness["context"].close()