"""A link Exec names is a link Wai can tap (WebKit / playwright). The regression, 2026-09-19: the biweekly EI nudge said "open the login link" and the panel rendered exactly that — no anchor — while the URL sat in the card's notes the whole time. The prompt half of the fix lives in `chat._CHAT_STATIC_PREFIX` / `nudge_llm._TONE`; this pins the render half. Three properties: * a markdown link in a NUDGE renders as an anchor that opens in a new tab. Same-tab is not a style choice here — the panel lives ON /rd and /hq, so following a link in place tears down the board, the panel, and the nudge's own unanswered answer row; * an unusable scheme keeps the label and drops the anchor; * a nudge's links are cyan like a reply's. `.msg.probe` is a panel-only role, so chat-msg.css's `.msg.assistant a` never reached it and it fell through to the UA's default blue. Boundaries mocked, no LLM and no board writes: /api/cc/exec-history serves a canned history, marked is stubbed with a link-only parser that honours the renderer option (the real one is a CDN fetch). Marked `browser` so the fast smoke step skips it. .venv/bin/pytest tests/test_exec_links_browser.py -q """ import json import pytest pytest.importorskip("playwright.sync_api") pytestmark = pytest.mark.browser # Enough marked to exercise the path under test: [text](href) through the # renderer the caller passes, everything else verbatim. _MARKED = """window.marked = { use(){}, Renderer: function(){}, parse: (s, o) => '
' + s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, (m, text, href) => (o && o.renderer && o.renderer.link) ? o.renderer.link({ href: href, text: text }) : m) + '
' };""" _LOGIN_URL = "https://srv265.hrdc-drhc.gc.ca/interdec/ouverturedesession-login/x.aspx?lang=eng" _HISTORY = { "messages": [ { "role": "monitor", "ts": "2026-09-19T09:00:00+00:00", "card_id": "card-ei", "text": f"Fifteen minutes, biweekly. [The EI login]({_LOGIN_URL}) is open in one tap.", }, { "role": "monitor", "ts": "2026-09-19T09:01:00+00:00", "card_id": "card-ei", "text": "[Nothing doing](javascript:alert(1)) is not a link.", }, ], "monitorTotal": 0, } @pytest.fixture def panel(browser, base_url, admin_headers): """/rd with the chat history canned and marked stubbed; panel open.""" ctx = browser.new_context(extra_http_headers=admin_headers) pg = ctx.new_page() pg.route("**/vendor/marked-*.min.js", lambda r: r.fulfill(status=200, content_type="application/javascript", body=_MARKED)) pg.route("**/hosaka-audio.js*", lambda r: r.fulfill(status=200, content_type="application/javascript", body="window.HosakaAudio={createPlayer:()=>({" "unlock(){},speak(){return Promise.resolve()}," "flush(){},setVolume(){},elapsed:()=>0," "audioDuration:()=>0,isUnlocked:()=>true," "gestureUnlocked:()=>true})};")) pg.route("**/api/cc/exec-history", lambda r: r.fulfill(status=200, content_type="application/json", body=json.dumps(_HISTORY))) pg.goto(f"{base_url}/rd", wait_until="domcontentloaded") pg.wait_for_selector("#exec-bubble", timeout=5000) pg.click("#exec-bubble") pg.wait_for_selector("#exec-panel.open", timeout=4000) pg.wait_for_selector("#exec-term .msg.probe", timeout=4000) yield pg try: ctx.close() except Exception: pass def test_a_nudge_link_is_an_anchor_that_opens_a_new_tab(panel): a = panel.locator("#exec-term .msg.probe a").first assert a.text_content() == "The EI login" assert a.get_attribute("href") == _LOGIN_URL # New tab, and no window.opener handed to the page it opens. assert a.get_attribute("target") == "_blank" assert a.get_attribute("rel") == "noopener" def test_an_unusable_scheme_keeps_the_label_and_drops_the_link(panel): """`javascript:` never becomes an anchor — the label survives as text.""" second = panel.locator("#exec-term .msg.probe").nth(1) assert "Nothing doing" in second.text_content() assert second.locator("a").count() == 0 def test_a_nudge_link_is_cyan_like_a_reply_link(panel): """`.msg.probe a` in exec-bubble.css — the shared vocabulary only covers `.msg.assistant a`, so without it a nudge's link renders UA-default blue.""" got, want = panel.evaluate("""() => { const a = document.querySelector('#exec-term .msg.probe a'); const probe = document.createElement('span'); probe.style.color = 'hsl(var(--cyan-hsl) / 0.8)'; document.body.appendChild(probe); const want = getComputedStyle(probe).color; probe.remove(); return [getComputedStyle(a).color, want]; }""") assert got == want