| // test_webui.cpp — Browser-based E2E tests for Ch5 Web UI. | |
| // Uses webdriver.h (cpp-httplib + json.hpp) to control headless Firefox. | |
| // | |
| // Usage: test_webui <port> | |
| // port: the translate-server port (e.g. 18080) | |
| #include "webdriver.h" | |
| #include <cstdlib> | |
| #include <iostream> | |
| #include <string> | |
| // ─── Test framework (minimal) ──────────────────────────────── | |
| static int pass_count = 0; | |
| static int fail_count = 0; | |
| #define PASS(label) \ | |
| do { \ | |
| std::cout << " PASS: " << (label) << "\n"; \ | |
| ++pass_count; \ | |
| } while (0) | |
| #define FAIL(label, detail) \ | |
| do { \ | |
| std::cout << " FAIL: " << (label) << "\n"; \ | |
| std::cout << " " << (detail) << "\n"; \ | |
| ++fail_count; \ | |
| } while (0) | |
| #define ASSERT_TRUE(cond, label) \ | |
| do { \ | |
| if (cond) { \ | |
| PASS(label); \ | |
| } else { \ | |
| FAIL(label, "condition was false"); \ | |
| } \ | |
| } while (0) | |
| #define ASSERT_CONTAINS(haystack, needle, label) \ | |
| do { \ | |
| if (std::string(haystack).find(needle) != std::string::npos) { \ | |
| PASS(label); \ | |
| } else { \ | |
| FAIL(label, "'" + std::string(haystack) + "' does not contain '" + \ | |
| std::string(needle) + "'"); \ | |
| } \ | |
| } while (0) | |
| #define ASSERT_ELEMENT_EXISTS(session, selector) \ | |
| do { \ | |
| try { \ | |
| (session).css(selector); \ | |
| PASS("Element " selector " exists"); \ | |
| } catch (...) { FAIL("Element " selector " exists", "not found"); } \ | |
| } while (0) | |
| // ─── Helpers ───────────────────────────────────────────────── | |
| static std::string base_url; | |
| void navigate_and_wait_for_models(webdriver::Session &session) { | |
| session.navigate(base_url); | |
| session.wait_until( | |
| "return document.querySelectorAll('#model-select option').length > 0", | |
| 5000); | |
| } | |
| void test_page_loads(webdriver::Session &session) { | |
| std::cout << "=== TC1: Page loads with correct structure\n"; | |
| session.navigate(base_url); | |
| auto title = session.title(); | |
| ASSERT_CONTAINS(title, "Translate", "Page title contains 'Translate'"); | |
| // Verify main DOM elements exist | |
| ASSERT_ELEMENT_EXISTS(session, "#model-select"); | |
| ASSERT_ELEMENT_EXISTS(session, "#input-text"); | |
| ASSERT_ELEMENT_EXISTS(session, "#output-text"); | |
| ASSERT_ELEMENT_EXISTS(session, "#target-lang"); | |
| } | |
| void test_model_dropdown(webdriver::Session &session) { | |
| std::cout << "=== TC2: Model dropdown is populated\n"; | |
| navigate_and_wait_for_models(session); | |
| // Note: WebDriver findElements cannot find <option> elements directly | |
| // in geckodriver/Firefox, so we use JS to count them. | |
| auto option_count = session.execute_script( | |
| "return document.querySelectorAll('#model-select option').length"); | |
| ASSERT_TRUE(option_count != "0" && option_count != "null", | |
| "Model dropdown has options (count=" + option_count + ")"); | |
| // Check that at least one option has a selected attribute | |
| auto selected_val = session.execute_script( | |
| "return document.querySelector('#model-select').value"); | |
| ASSERT_TRUE(selected_val != "null" && !selected_val.empty(), | |
| "A model is selected (value='" + selected_val + "')"); | |
| } | |
| void test_translation_sse(webdriver::Session &session) { | |
| std::cout << "=== TC3: Translation with SSE streaming\n"; | |
| navigate_and_wait_for_models(session); | |
| // Clear and type input — debounce auto-translate triggers after 300ms | |
| auto input = session.css("#input-text"); | |
| input.clear(); | |
| input.send_keys("Hello world"); | |
| // Wait for output to appear (debounce 300ms + LLM inference) | |
| bool has_output = session.wait_until( | |
| "return document.querySelector('#output-text').textContent.length > 0", | |
| 120000); | |
| ASSERT_TRUE(has_output, "Translation output appeared"); | |
| auto output_text = session.execute_script( | |
| "return document.querySelector('#output-text').textContent"); | |
| ASSERT_TRUE(!output_text.empty() && output_text != "null", | |
| "Output text is non-empty ('" + output_text.substr(0, 50) + | |
| "...')"); | |
| // Wait for busy state to be cleared after completion | |
| bool busy_cleared = session.wait_until( | |
| "return !document.body.classList.contains('busy')", 120000); | |
| ASSERT_TRUE(busy_cleared, "Busy state cleared after translation"); | |
| } | |
| void test_busy_state(webdriver::Session &session) { | |
| std::cout << "=== TC4: Busy state during translation\n"; | |
| navigate_and_wait_for_models(session); | |
| auto input = session.css("#input-text"); | |
| input.clear(); | |
| // Clear previous output | |
| session.execute_script( | |
| "document.querySelector('#output-text').textContent = ''"); | |
| input.send_keys( | |
| "I had a great time visiting Tokyo last spring. " | |
| "The cherry blossoms were beautiful and the food was amazing."); | |
| // Check busy state (debounce 300ms then translation starts) | |
| bool went_busy = session.wait_until( | |
| "return document.body.classList.contains('busy')", 5000); | |
| ASSERT_TRUE(went_busy, "Body gets 'busy' class during translation"); | |
| // Wait for completion | |
| session.wait_until("return !document.body.classList.contains('busy')", | |
| 120000); | |
| PASS("Busy class removed after completion"); | |
| } | |
| void test_empty_input(webdriver::Session &session) { | |
| std::cout << "=== TC5: Empty input does nothing\n"; | |
| navigate_and_wait_for_models(session); | |
| // Clear input and output | |
| auto input = session.css("#input-text"); | |
| input.clear(); | |
| session.execute_script( | |
| "document.querySelector('#output-text').textContent = ''"); | |
| // Trigger input event on empty textarea | |
| session.execute_script("document.querySelector('#input-text').dispatchEvent(" | |
| " new Event('input'));"); | |
| // Wait longer than debounce (300ms) — nothing should happen | |
| std::this_thread::sleep_for(std::chrono::milliseconds(1000)); | |
| auto output_text = session.execute_script( | |
| "return document.querySelector('#output-text').textContent"); | |
| ASSERT_TRUE(output_text.empty() || output_text == "null" || output_text == "", | |
| "No output for empty input"); | |
| } | |
| void test_target_lang_selector(webdriver::Session &session) { | |
| std::cout << "=== TC6: Target language selector\n"; | |
| navigate_and_wait_for_models(session); | |
| // Check available language options (use JS — WebDriver can't find <option>) | |
| auto lang_count = session.execute_script( | |
| "return document.querySelectorAll('#target-lang option').length"); | |
| ASSERT_TRUE(lang_count != "0" && lang_count != "null", | |
| "Language selector has multiple options (count=" + lang_count + | |
| ")"); | |
| // Switch to English and translate | |
| session.execute_script("document.querySelector('#target-lang').value = 'en';" | |
| "document.querySelector('#target-lang').dispatchEvent(" | |
| " new Event('change'));"); | |
| // Clear output, then type — debounce auto-translate triggers | |
| session.execute_script( | |
| "document.querySelector('#output-text').textContent = ''"); | |
| auto input = session.css("#input-text"); | |
| input.clear(); | |
| input.send_keys("こんにちは"); | |
| bool has_output = session.wait_until( | |
| "return document.querySelector('#output-text').textContent.length > 0", | |
| 120000); | |
| ASSERT_TRUE(has_output, "Translation with target_lang=en produced output"); | |
| } | |
| void test_model_switch(webdriver::Session &session) { | |
| std::cout << "=== TC7: Model switching\n"; | |
| navigate_and_wait_for_models(session); | |
| auto options = session.css_all("#model-select option"); | |
| if (options.size() < 2) { | |
| PASS("Model switch skipped (only 1 model available)"); | |
| return; | |
| } | |
| // Get current model | |
| auto current = session.execute_script( | |
| "return document.querySelector('#model-select').value"); | |
| // Switch to a different model (pick the second option's value) | |
| auto other_value = options[1].attribute("value"); | |
| if (other_value == current && options.size() > 2) { | |
| other_value = options[2].attribute("value"); | |
| } | |
| session.execute_script( | |
| "document.querySelector('#model-select').value = '" + other_value + | |
| "';" | |
| "document.querySelector('#model-select').dispatchEvent(" | |
| " new Event('change'));"); | |
| // Wait for model switch to complete (SSE: downloading → loading → ready) | |
| bool ready = session.wait_until( | |
| "return !document.body.classList.contains('busy')", 120000); | |
| ASSERT_TRUE(ready, "Model switch completed"); | |
| auto new_value = session.execute_script( | |
| "return document.querySelector('#model-select').value"); | |
| ASSERT_TRUE(new_value == other_value, | |
| "Model changed to '" + other_value + "'"); | |
| } | |
| void test_download_dialog_structure(webdriver::Session &session) { | |
| std::cout << "=== TC8: Download dialog DOM structure\n"; | |
| session.navigate(base_url); | |
| ASSERT_ELEMENT_EXISTS(session, "#download-dialog"); | |
| ASSERT_ELEMENT_EXISTS(session, "#download-progress"); | |
| ASSERT_ELEMENT_EXISTS(session, "#download-status"); | |
| ASSERT_ELEMENT_EXISTS(session, "#download-cancel"); | |
| } | |
| // ─── Main ──────────────────────────────────────────────────── | |
| int main(int argc, char *argv[]) { | |
| if (argc < 2) { | |
| std::cerr << "Usage: test_webui <server-port>\n"; | |
| return 1; | |
| } | |
| int port = std::atoi(argv[1]); | |
| base_url = "http://127.0.0.1:" + std::to_string(port); | |
| std::cout << "=== Ch5 Web UI Browser Tests\n"; | |
| std::cout << "=== Server: " << base_url << "\n\n"; | |
| try { | |
| webdriver::Session session; | |
| test_page_loads(session); | |
| test_model_dropdown(session); | |
| test_translation_sse(session); | |
| test_busy_state(session); | |
| test_empty_input(session); | |
| test_target_lang_selector(session); | |
| test_model_switch(session); | |
| test_download_dialog_structure(session); | |
| } catch (const webdriver::Error &e) { | |
| std::cerr << "WebDriver error: " << e.what() << "\n"; | |
| ++fail_count; | |
| } catch (const std::exception &e) { | |
| std::cerr << "Error: " << e.what() << "\n"; | |
| ++fail_count; | |
| } | |
| std::cout << "\n=== Results: " << pass_count << " passed, " << fail_count | |
| << " failed\n"; | |
| return fail_count > 0 ? 1 : 0; | |
| } |