|
| 1 | +"""Playwright browser tests for copy button and search functionality. |
| 2 | +
|
| 3 | +These tests are skipped if Playwright browsers are not installed. |
| 4 | +Install browsers with: playwright install chromium |
| 5 | +""" |
| 6 | + |
| 7 | +import json |
| 8 | +import tempfile |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +import pytest |
| 12 | + |
| 13 | +# Check if Playwright browsers are available |
| 14 | +try: |
| 15 | + from playwright.sync_api import sync_playwright |
| 16 | + |
| 17 | + def _check_browser_available(): |
| 18 | + try: |
| 19 | + with sync_playwright() as p: |
| 20 | + browser = p.chromium.launch() |
| 21 | + browser.close() |
| 22 | + return True |
| 23 | + except Exception: |
| 24 | + return False |
| 25 | + |
| 26 | + PLAYWRIGHT_AVAILABLE = _check_browser_available() |
| 27 | +except ImportError: |
| 28 | + PLAYWRIGHT_AVAILABLE = False |
| 29 | + |
| 30 | +pytestmark = pytest.mark.skipif( |
| 31 | + not PLAYWRIGHT_AVAILABLE, |
| 32 | + reason="Playwright browsers not available. Install with: playwright install chromium", |
| 33 | +) |
| 34 | + |
| 35 | + |
| 36 | +@pytest.fixture |
| 37 | +def generated_html(tmp_path): |
| 38 | + """Generate HTML files from the sample session for testing.""" |
| 39 | + from claude_code_transcripts import generate_html |
| 40 | + |
| 41 | + fixture_path = Path(__file__).parent / "sample_session.json" |
| 42 | + output_dir = tmp_path / "output" |
| 43 | + output_dir.mkdir() |
| 44 | + generate_html(fixture_path, output_dir, github_repo="example/project") |
| 45 | + return output_dir |
| 46 | + |
| 47 | + |
| 48 | +@pytest.fixture |
| 49 | +def page(generated_html): |
| 50 | + """Create a Playwright page for testing.""" |
| 51 | + from playwright.sync_api import sync_playwright |
| 52 | + |
| 53 | + with sync_playwright() as p: |
| 54 | + browser = p.chromium.launch() |
| 55 | + page = browser.new_page() |
| 56 | + yield page, generated_html |
| 57 | + browser.close() |
| 58 | + |
| 59 | + |
| 60 | +class TestCopyButtonPlaywright: |
| 61 | + """Playwright tests for the copy button feature.""" |
| 62 | + |
| 63 | + def test_copy_button_visible_in_message(self, page): |
| 64 | + """Test that copy buttons are visible in messages.""" |
| 65 | + page_obj, output_dir = page |
| 66 | + page_obj.goto(f"file://{output_dir}/page-001.html") |
| 67 | + |
| 68 | + # Wait for copy buttons to be rendered |
| 69 | + copy_buttons = page_obj.locator("copy-button") |
| 70 | + assert copy_buttons.count() > 0, "Copy buttons should be present in messages" |
| 71 | + |
| 72 | + def test_copy_button_visible_in_index(self, page): |
| 73 | + """Test that copy buttons are visible in index items.""" |
| 74 | + page_obj, output_dir = page |
| 75 | + page_obj.goto(f"file://{output_dir}/index.html") |
| 76 | + |
| 77 | + # Wait for copy buttons to be rendered |
| 78 | + copy_buttons = page_obj.locator("copy-button") |
| 79 | + assert copy_buttons.count() > 0, "Copy buttons should be present in index" |
| 80 | + |
| 81 | + def test_copy_button_click_changes_text(self, page): |
| 82 | + """Test that clicking a copy button changes the text to 'Copied!'.""" |
| 83 | + page_obj, output_dir = page |
| 84 | + page_obj.goto(f"file://{output_dir}/page-001.html") |
| 85 | + |
| 86 | + # Grant clipboard permissions |
| 87 | + page_obj.context.grant_permissions(["clipboard-write", "clipboard-read"]) |
| 88 | + |
| 89 | + # Find first copy button and click it |
| 90 | + copy_button = page_obj.locator("copy-button").first |
| 91 | + copy_button.wait_for(state="attached") |
| 92 | + |
| 93 | + # Get the shadow DOM button |
| 94 | + button = copy_button.locator("button") |
| 95 | + original_text = button.inner_text() |
| 96 | + |
| 97 | + # Click the button |
| 98 | + button.click() |
| 99 | + |
| 100 | + # Check that text changed to "Copied!" |
| 101 | + assert ( |
| 102 | + button.inner_text() == "Copied!" |
| 103 | + ), "Button text should change to 'Copied!' after click" |
| 104 | + |
| 105 | + # Wait 2.5 seconds and check it reverts |
| 106 | + page_obj.wait_for_timeout(2500) |
| 107 | + assert ( |
| 108 | + button.inner_text() == original_text |
| 109 | + ), "Button text should revert after 2 seconds" |
| 110 | + |
| 111 | + def test_copy_button_copies_markdown_content(self, page): |
| 112 | + """Test that clicking a copy button actually copies content.""" |
| 113 | + page_obj, output_dir = page |
| 114 | + page_obj.goto(f"file://{output_dir}/page-001.html") |
| 115 | + |
| 116 | + # Grant clipboard permissions |
| 117 | + page_obj.context.grant_permissions(["clipboard-write", "clipboard-read"]) |
| 118 | + |
| 119 | + # Find a copy button with data-content (markdown) |
| 120 | + copy_button = page_obj.locator("copy-button[data-content]").first |
| 121 | + copy_button.wait_for(state="attached") |
| 122 | + |
| 123 | + # Get the content that should be copied |
| 124 | + expected_content = copy_button.get_attribute("data-content") |
| 125 | + |
| 126 | + # Click the button |
| 127 | + button = copy_button.locator("button") |
| 128 | + button.click() |
| 129 | + |
| 130 | + # Read from clipboard |
| 131 | + clipboard_content = page_obj.evaluate("navigator.clipboard.readText()") |
| 132 | + |
| 133 | + assert ( |
| 134 | + clipboard_content == expected_content |
| 135 | + ), "Clipboard should contain the data-content value" |
| 136 | + |
| 137 | + |
| 138 | +class TestSearchPlaywright: |
| 139 | + """Playwright tests for the search feature.""" |
| 140 | + |
| 141 | + def test_search_box_hidden_by_default(self, page): |
| 142 | + """Test that search box is hidden by default (progressive enhancement).""" |
| 143 | + page_obj, output_dir = page |
| 144 | + # Load the page with JS disabled to test progressive enhancement |
| 145 | + page_obj.context.set_offline(False) |
| 146 | + page_obj.goto(f"file://{output_dir}/index.html", wait_until="domcontentloaded") |
| 147 | + |
| 148 | + # Note: With file:// protocol, search is hidden |
| 149 | + # This test verifies the file:// protocol behavior |
| 150 | + search_box = page_obj.locator("#search-box") |
| 151 | + # On file:// protocol, search box should remain hidden |
| 152 | + assert not search_box.is_visible() or search_box.evaluate( |
| 153 | + "el => getComputedStyle(el).display === 'none'" |
| 154 | + ) |
| 155 | + |
| 156 | + def test_search_modal_exists(self, page): |
| 157 | + """Test that the search modal element exists.""" |
| 158 | + page_obj, output_dir = page |
| 159 | + page_obj.goto(f"file://{output_dir}/index.html") |
| 160 | + |
| 161 | + # The modal should exist in the DOM |
| 162 | + modal = page_obj.locator("#search-modal") |
| 163 | + assert modal.count() == 1, "Search modal should exist" |
| 164 | + |
| 165 | + |
| 166 | +class TestTimestampFormatting: |
| 167 | + """Test that timestamps are formatted correctly by JavaScript.""" |
| 168 | + |
| 169 | + def test_timestamps_formatted(self, page): |
| 170 | + """Test that timestamps are formatted by JavaScript.""" |
| 171 | + page_obj, output_dir = page |
| 172 | + page_obj.goto(f"file://{output_dir}/page-001.html") |
| 173 | + |
| 174 | + # Wait for JS to run |
| 175 | + page_obj.wait_for_load_state("networkidle") |
| 176 | + |
| 177 | + # Get a timestamp element |
| 178 | + time_element = page_obj.locator("time[data-timestamp]").first |
| 179 | + time_element.wait_for(state="attached") |
| 180 | + |
| 181 | + # The text should be formatted (not the raw ISO timestamp) |
| 182 | + text = time_element.inner_text() |
| 183 | + # Raw format is like "2025-01-01T12:00:00.000Z" |
| 184 | + # Formatted should be like "Jan 1 12:00" or just "12:00" |
| 185 | + assert "T" not in text, "Timestamp should be formatted, not raw ISO format" |
| 186 | + assert "Z" not in text, "Timestamp should be formatted, not raw ISO format" |
0 commit comments