f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
467 lines
18 KiB
Python
467 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Browser integration test for diff nav button fix using Playwright.
|
|
|
|
Generates a self-contained HTML viewer with synthetic trace data,
|
|
opens it in a headless browser, and verifies that the ◀/▶ nav buttons
|
|
are correctly enabled/disabled after the .idx bug fix.
|
|
"""
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pw_missing = False
|
|
try:
|
|
from playwright.sync_api import sync_playwright # noqa: F401
|
|
except ImportError:
|
|
pw_missing = True
|
|
|
|
pytestmark = pytest.mark.skipif(pw_missing, reason="playwright not installed")
|
|
|
|
# ── Synthetic trace data: 4-turn conversation chain ──
|
|
# Turn 1 → Turn 2 → Turn 3 → Turn 4
|
|
# This gives us 3 diff pairs to navigate between.
|
|
|
|
|
|
def _make_entry(turn: int, messages: list[dict]) -> dict:
|
|
"""Build a trace entry matching the real JSONL format."""
|
|
return {
|
|
"timestamp": f"2026-02-24T20:00:0{turn}",
|
|
"request_id": f"req_{turn}",
|
|
"turn": turn,
|
|
"duration_ms": 500,
|
|
"request": {
|
|
"method": "POST",
|
|
"path": "/v1/messages",
|
|
"headers": {},
|
|
"body": {
|
|
"model": "claude-opus-4-6",
|
|
"system": [{"type": "text", "text": "You are Claude"}],
|
|
"messages": messages,
|
|
},
|
|
},
|
|
"response": {
|
|
"status": 200,
|
|
"body": {
|
|
"content": [{"type": "text", "text": f"Response for turn {turn}"}],
|
|
"model": "claude-opus-4-6",
|
|
"usage": {"input_tokens": turn * 10, "output_tokens": 5},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
TRACE_ENTRIES = [
|
|
_make_entry(1, [{"role": "user", "content": "hello"}]),
|
|
_make_entry(
|
|
2,
|
|
[
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "Hi!"},
|
|
{"role": "user", "content": "how are you"},
|
|
],
|
|
),
|
|
_make_entry(
|
|
3,
|
|
[
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "Hi!"},
|
|
{"role": "user", "content": "how are you"},
|
|
{"role": "assistant", "content": "I'm fine!"},
|
|
{"role": "user", "content": "tell me a joke"},
|
|
],
|
|
),
|
|
_make_entry(
|
|
4,
|
|
[
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "Hi!"},
|
|
{"role": "user", "content": "how are you"},
|
|
{"role": "assistant", "content": "I'm fine!"},
|
|
{"role": "user", "content": "tell me a joke"},
|
|
{"role": "assistant", "content": "Why did the chicken..."},
|
|
{"role": "user", "content": "another one"},
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
def _build_test_html() -> str:
|
|
"""Generate self-contained viewer HTML with embedded test trace data."""
|
|
from claude_tap.viewer import VIEWER_SCRIPT_ANCHOR, _read_viewer_template
|
|
|
|
html = _read_viewer_template()
|
|
|
|
records = [json.dumps(e) for e in TRACE_ENTRIES]
|
|
data_js = (
|
|
"const EMBEDDED_TRACE_DATA = [\n" + ",\n".join(records) + "\n];\n"
|
|
'const __TRACE_JSONL_PATH__ = "/tmp/test.jsonl";\n'
|
|
'const __TRACE_HTML_PATH__ = "/tmp/test.html";\n'
|
|
)
|
|
html = html.replace(
|
|
VIEWER_SCRIPT_ANCHOR,
|
|
f"<script>\n{data_js}</script>\n{VIEWER_SCRIPT_ANCHOR}",
|
|
1,
|
|
)
|
|
return html
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def html_file():
|
|
"""Write test HTML to a temp file."""
|
|
html = _build_test_html()
|
|
with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as f:
|
|
f.write(html)
|
|
return Path(f.name)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def browser_page(html_file):
|
|
"""Launch headless Chromium and open the test HTML."""
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
pw = sync_playwright().start()
|
|
browser = pw.chromium.launch(headless=True)
|
|
page = browser.new_page()
|
|
page.goto(f"file://{html_file}")
|
|
page.wait_for_selector(".sidebar-item", timeout=5000)
|
|
yield page
|
|
browser.close()
|
|
pw.stop()
|
|
|
|
|
|
class TestDiffNavInBrowser:
|
|
"""Test diff navigation buttons in an actual browser."""
|
|
|
|
def _open_diff_for_entry(self, page, entry_index: int):
|
|
"""Click the diff button on the Nth entry (0-indexed in filtered list)."""
|
|
# Close any existing diff overlay first
|
|
page.evaluate("document.querySelector('.diff-overlay')?.remove()")
|
|
# Click the sidebar item to select it
|
|
items = page.query_selector_all(".sidebar-item")
|
|
items[entry_index].click()
|
|
page.wait_for_timeout(200)
|
|
# Click the diff button (showDiff) in the action bar
|
|
page.evaluate("document.querySelector('.act-btn:nth-child(3)').click()")
|
|
page.wait_for_selector(".diff-overlay", timeout=3000)
|
|
|
|
def _get_nav_state(self, page) -> dict:
|
|
"""Get the current state of the diff nav buttons."""
|
|
return page.evaluate("""() => {
|
|
const overlay = document.querySelector('.diff-overlay');
|
|
if (!overlay) return { overlayExists: false };
|
|
const prev = overlay.querySelector('.diff-nav-prev');
|
|
const next = overlay.querySelector('.diff-nav-next');
|
|
const title = overlay.querySelector('.diff-title')?.textContent || '';
|
|
return {
|
|
overlayExists: true,
|
|
prevDisabled: prev?.disabled ?? null,
|
|
nextDisabled: next?.disabled ?? null,
|
|
title: title,
|
|
};
|
|
}""")
|
|
|
|
def test_entry_count(self, browser_page):
|
|
"""Verify all 4 entries loaded."""
|
|
count = browser_page.evaluate("document.querySelectorAll('.sidebar-item').length")
|
|
assert count == 4, f"Expected 4 entries, got {count}"
|
|
|
|
def test_first_diff_pair_nav_state(self, browser_page):
|
|
"""At diff Turn1→Turn2 (first pair): prev disabled, next enabled."""
|
|
self._open_diff_for_entry(browser_page, 1)
|
|
state = self._get_nav_state(browser_page)
|
|
assert state["overlayExists"], "Diff overlay should be open"
|
|
assert state["prevDisabled"] is True, (
|
|
f"BUG if False: prev should be disabled at first diff pair. State: {state}"
|
|
)
|
|
assert state["nextDisabled"] is False, (
|
|
f"BUG if True: next should be enabled (can go to Turn2→Turn3). State: {state}"
|
|
)
|
|
|
|
def test_middle_diff_pair_nav_state(self, browser_page):
|
|
"""At diff Turn2→Turn3 (middle pair): both enabled."""
|
|
self._open_diff_for_entry(browser_page, 2)
|
|
state = self._get_nav_state(browser_page)
|
|
assert state["overlayExists"], "Diff overlay should be open"
|
|
assert state["prevDisabled"] is False, f"prev should be enabled (can go back to Turn1→Turn2). State: {state}"
|
|
assert state["nextDisabled"] is False, f"next should be enabled (can go to Turn3→Turn4). State: {state}"
|
|
|
|
def test_last_diff_pair_nav_state(self, browser_page):
|
|
"""At diff Turn3→Turn4 (last pair): prev enabled, next disabled."""
|
|
self._open_diff_for_entry(browser_page, 3)
|
|
state = self._get_nav_state(browser_page)
|
|
assert state["overlayExists"], "Diff overlay should be open"
|
|
assert state["prevDisabled"] is False, f"prev should be enabled (can go back to Turn2→Turn3). State: {state}"
|
|
assert state["nextDisabled"] is True, f"next should be disabled at last diff pair. State: {state}"
|
|
|
|
def test_click_right_navigates(self, browser_page):
|
|
"""Clicking ▶ at Turn1→Turn2 should navigate to Turn2→Turn3."""
|
|
self._open_diff_for_entry(browser_page, 1)
|
|
state_before = self._get_nav_state(browser_page)
|
|
assert "1" in state_before["title"] and "2" in state_before["title"]
|
|
|
|
# Click right arrow
|
|
browser_page.click(".diff-nav-next")
|
|
browser_page.wait_for_timeout(300)
|
|
|
|
state_after = self._get_nav_state(browser_page)
|
|
assert state_after["overlayExists"], "Overlay should still exist after clicking ▶"
|
|
assert "2" in state_after["title"] and "3" in state_after["title"], (
|
|
f"Should navigate to Turn2→Turn3, got title: {state_after['title']}"
|
|
)
|
|
|
|
def test_click_left_at_first_does_not_close(self, browser_page):
|
|
"""Clicking ◀ at Turn1→Turn2 should NOT close the overlay (regression test)."""
|
|
self._open_diff_for_entry(browser_page, 1)
|
|
state = self._get_nav_state(browser_page)
|
|
assert state["prevDisabled"] is True, "prev should be disabled"
|
|
|
|
# Even if we try to click the disabled button, overlay should remain
|
|
browser_page.evaluate("""
|
|
document.querySelector('.diff-nav-prev').click();
|
|
""")
|
|
browser_page.wait_for_timeout(300)
|
|
|
|
state_after = self._get_nav_state(browser_page)
|
|
assert state_after["overlayExists"], "BUG: overlay disappeared after clicking disabled ◀ at first diff pair!"
|
|
|
|
def test_click_left_navigates_back(self, browser_page):
|
|
"""Clicking ◀ at Turn2→Turn3 should navigate back to Turn1→Turn2."""
|
|
self._open_diff_for_entry(browser_page, 2)
|
|
state_before = self._get_nav_state(browser_page)
|
|
assert "2" in state_before["title"] and "3" in state_before["title"]
|
|
|
|
# Click left arrow
|
|
browser_page.click(".diff-nav-prev")
|
|
browser_page.wait_for_timeout(300)
|
|
|
|
state_after = self._get_nav_state(browser_page)
|
|
assert state_after["overlayExists"], "Overlay should still exist after clicking ◀"
|
|
assert "1" in state_after["title"] and "2" in state_after["title"], (
|
|
f"Should navigate to Turn1→Turn2, got title: {state_after['title']}"
|
|
)
|
|
|
|
def test_keyboard_right_arrow(self, browser_page):
|
|
"""Pressing → key at Turn1→Turn2 should navigate to Turn2→Turn3."""
|
|
self._open_diff_for_entry(browser_page, 1)
|
|
|
|
browser_page.keyboard.press("ArrowRight")
|
|
browser_page.wait_for_timeout(300)
|
|
|
|
state = self._get_nav_state(browser_page)
|
|
assert state["overlayExists"], "Overlay should exist after pressing →"
|
|
assert "2" in state["title"] and "3" in state["title"], (
|
|
f"→ should navigate to Turn2→Turn3, got: {state['title']}"
|
|
)
|
|
|
|
def test_keyboard_left_at_first_keeps_overlay(self, browser_page):
|
|
"""Pressing ← at first diff should NOT close overlay (regression)."""
|
|
self._open_diff_for_entry(browser_page, 1)
|
|
|
|
browser_page.keyboard.press("ArrowLeft")
|
|
browser_page.wait_for_timeout(300)
|
|
|
|
state = self._get_nav_state(browser_page)
|
|
assert state["overlayExists"], "BUG: overlay disappeared after pressing ← at first diff!"
|
|
|
|
def test_full_chain_traversal_right(self, browser_page):
|
|
"""Navigate the full chain: Turn1→2, then →Turn2→3, then →Turn3→4."""
|
|
self._open_diff_for_entry(browser_page, 1)
|
|
|
|
# At Turn1→Turn2
|
|
s = self._get_nav_state(browser_page)
|
|
assert "1" in s["title"] and "2" in s["title"]
|
|
|
|
# → to Turn2→Turn3
|
|
browser_page.click(".diff-nav-next")
|
|
browser_page.wait_for_timeout(300)
|
|
s = self._get_nav_state(browser_page)
|
|
assert "2" in s["title"] and "3" in s["title"], f"Got: {s['title']}"
|
|
|
|
# → to Turn3→Turn4
|
|
browser_page.click(".diff-nav-next")
|
|
browser_page.wait_for_timeout(300)
|
|
s = self._get_nav_state(browser_page)
|
|
assert "3" in s["title"] and "4" in s["title"], f"Got: {s['title']}"
|
|
|
|
# Next should now be disabled
|
|
assert s["nextDisabled"] is True, "Should be at end of chain"
|
|
|
|
def test_full_chain_traversal_left(self, browser_page):
|
|
"""Navigate backward: start at Turn3→4, then ← all the way."""
|
|
self._open_diff_for_entry(browser_page, 3)
|
|
|
|
# At Turn3→Turn4
|
|
s = self._get_nav_state(browser_page)
|
|
assert "3" in s["title"] and "4" in s["title"]
|
|
|
|
# ← to Turn2→Turn3
|
|
browser_page.click(".diff-nav-prev")
|
|
browser_page.wait_for_timeout(300)
|
|
s = self._get_nav_state(browser_page)
|
|
assert "2" in s["title"] and "3" in s["title"], f"Got: {s['title']}"
|
|
|
|
# ← to Turn1→Turn2
|
|
browser_page.click(".diff-nav-prev")
|
|
browser_page.wait_for_timeout(300)
|
|
s = self._get_nav_state(browser_page)
|
|
assert "1" in s["title"] and "2" in s["title"], f"Got: {s['title']}"
|
|
|
|
# Prev should now be disabled
|
|
assert s["prevDisabled"] is True, "Should be at start of chain"
|
|
|
|
def test_codex_thread_hidden_prefetch_parent(self, browser_page):
|
|
"""Codex Responses diff should skip hidden prefetch parents."""
|
|
result = browser_page.evaluate("""
|
|
() => {
|
|
const oldEntries = entries;
|
|
const oldFiltered = filtered;
|
|
const metadata = {
|
|
"x-codex-turn-metadata": JSON.stringify({ session_id: "session-a", thread_id: "thread-a" })
|
|
};
|
|
try {
|
|
filtered = [
|
|
{
|
|
turn: "1.1",
|
|
request_id: "req-1",
|
|
request: { body: {
|
|
model: "gpt-5.5",
|
|
client_metadata: metadata,
|
|
messages: [{ role: "user", content: "initial" }]
|
|
}},
|
|
response: { body: { id: "resp_1_1", content: [{ type: "text", text: "one" }] } }
|
|
},
|
|
{
|
|
turn: "3.2",
|
|
request_id: "req-2",
|
|
request: { body: {
|
|
model: "gpt-5.5",
|
|
client_metadata: metadata,
|
|
previous_response_id: "resp_3_1",
|
|
messages: [
|
|
{ role: "user", content: "initial" },
|
|
{ role: "assistant", content: "earlier final text" },
|
|
{ role: "user", content: "limit compare" },
|
|
{ role: "assistant", content: [{ type: "tool_use", name: "tool_search", input: { limit: 8 } }] },
|
|
{ role: "tool", content: "github tools" }
|
|
]
|
|
}},
|
|
response: { body: { id: "resp_3_2", content: [{ type: "text", text: "two" }] } }
|
|
},
|
|
{
|
|
turn: "4.1",
|
|
request_id: "req-3",
|
|
request: { body: {
|
|
model: "gpt-5.5",
|
|
client_metadata: metadata,
|
|
previous_response_id: "resp_hidden_prefetch",
|
|
messages: [
|
|
{ role: "user", content: "initial" },
|
|
{ role: "assistant", content: "earlier final text" },
|
|
{ role: "user", content: "figma domain" },
|
|
{ role: "tool", content: "older tool outputs" }
|
|
]
|
|
}},
|
|
response: { body: { id: "resp_4_1", content: [{ type: "text", text: "three" }] } }
|
|
}
|
|
];
|
|
entries = filtered;
|
|
return {
|
|
parent: findPrevSameModel(2),
|
|
titleBefore: filtered[findPrevSameModel(2).idx].turn,
|
|
nextFromMiddle: findNextSameModel(1),
|
|
};
|
|
} finally {
|
|
entries = oldEntries;
|
|
filtered = oldFiltered;
|
|
}
|
|
}
|
|
""")
|
|
|
|
assert result == {
|
|
"parent": {"idx": 1, "isFallback": False},
|
|
"titleBefore": "3.2",
|
|
"nextFromMiddle": 2,
|
|
}
|
|
|
|
def test_parameter_diff_renders_field_level_line_diff(self, browser_page):
|
|
"""Changed params should be rendered per field, not as opaque JSON blobs."""
|
|
result = browser_page.evaluate("""
|
|
() => {
|
|
const oldBody = {
|
|
previous_response_id: "resp_old",
|
|
client_metadata: {
|
|
"x-codex-turn-metadata": JSON.stringify({ turn_id: "turn-old", has_changes: true })
|
|
}
|
|
};
|
|
const newBody = {
|
|
previous_response_id: "resp_new",
|
|
client_metadata: {
|
|
"x-codex-turn-metadata": JSON.stringify({ turn_id: "turn-new", has_changes: false })
|
|
}
|
|
};
|
|
const host = document.createElement("div");
|
|
host.innerHTML = renderStructuralDiff(structuralDiff(oldBody, newBody));
|
|
const paramBlocks = Array.from(host.querySelectorAll(".diff-param-change"));
|
|
return {
|
|
paramCount: paramBlocks.length,
|
|
keys: paramBlocks.map(block => block.querySelector(".diff-param-key")?.textContent),
|
|
hasLineDiff: host.querySelectorAll(".diff-param-body .sbs-cell").length > 0,
|
|
text: host.innerText,
|
|
};
|
|
}
|
|
""")
|
|
|
|
assert result["paramCount"] == 2
|
|
assert result["keys"] == ["previous_response_id", "client_metadata"]
|
|
assert result["hasLineDiff"] is True
|
|
assert "turn-old" in result["text"]
|
|
assert "turn-new" in result["text"]
|
|
|
|
def test_added_tool_diff_is_expandable_with_schema(self, browser_page):
|
|
"""Added namespace tools should expose their full schema in the diff."""
|
|
result = browser_page.evaluate("""
|
|
() => {
|
|
const oldBody = { tools: [{ type: "function", name: "tool_search" }] };
|
|
const newBody = { tools: [
|
|
{ type: "function", name: "tool_search" },
|
|
{
|
|
type: "namespace",
|
|
name: "mcp__codex_apps__github",
|
|
description: "Access repositories, issues, and pull requests.",
|
|
tools: [{
|
|
type: "function",
|
|
name: "_fetch_pr",
|
|
description: "Fetch a pull request.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
repo_full_name: { type: "string", description: "Repository in owner/name form." },
|
|
pr_number: { type: "integer", description: "Pull request number." }
|
|
},
|
|
required: ["repo_full_name", "pr_number"]
|
|
}
|
|
}]
|
|
}
|
|
] };
|
|
const host = document.createElement("div");
|
|
host.innerHTML = renderStructuralDiff(structuralDiff(oldBody, newBody));
|
|
const detail = host.querySelector(".diff-tool-detail");
|
|
const beforeOpen = detail.open;
|
|
detail.open = true;
|
|
return {
|
|
beforeOpen,
|
|
summary: detail.querySelector("summary")?.innerText,
|
|
body: detail.querySelector(".diff-tool-body")?.innerText,
|
|
json: detail.querySelector(".diff-tool-json")?.textContent,
|
|
};
|
|
}
|
|
""")
|
|
|
|
assert result["beforeOpen"] is False
|
|
assert "mcp__codex_apps__github" in result["summary"]
|
|
assert "_fetch_pr" in result["body"]
|
|
assert "repo_full_name" in result["json"]
|