diff --git a/quickjs.docs.md b/quickjs.docs.md new file mode 100644 index 00000000..be11c3d3 --- /dev/null +++ b/quickjs.docs.md @@ -0,0 +1 @@ +Execute JavaScript code in a sandboxed QuickJS environment running via WebAssembly. Code is automatically saved in the URL hash for easy sharing. diff --git a/quickjs.html b/quickjs.html new file mode 100644 index 00000000..8ab35e05 --- /dev/null +++ b/quickjs.html @@ -0,0 +1,596 @@ + + + + + + QuickJS Code Executor + + + +

QuickJS Code Executor

+

Execute JavaScript code in a sandboxed QuickJS environment running via WebAssembly. Code is saved in the URL for easy sharing.

+ +
+

Try an example:

+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+

Output

+ +
+
+
+
+ + + + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..5cff4526 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,89 @@ +""" +Pytest configuration and fixtures for the tools tests. +""" + +import socket +import subprocess +import sys +import time +import pytest + + +def find_unused_port(): + """Find and return an unused port number on localhost.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + return port + finally: + sock.close() + + +class StaticServer: + """Manages a static file HTTP server with proper startup waiting.""" + + def __init__(self, port): + self.port = port + self._process = None + self._directory = None + + def start(self, directory='.'): + """Start an HTTP server serving the specified directory.""" + if self._process is not None: + raise RuntimeError("Server is already running") + + self._directory = directory + self._process = subprocess.Popen( + [sys.executable, '-m', 'http.server', str(self.port), '--directory', str(directory)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # Wait for the server to be ready (up to 5 seconds) + import urllib.request + import urllib.error + + for _ in range(50): # 50 * 0.1 = 5 seconds max + time.sleep(0.1) + + if self._process.poll() is not None: + stdout, stderr = self._process.communicate() + raise RuntimeError(f"Server failed to start: {stderr}") + + try: + urllib.request.urlopen(f'http://127.0.0.1:{self.port}/', timeout=1) + return self # Server is ready + except (urllib.error.URLError, ConnectionRefusedError): + continue + + raise RuntimeError("Server did not become ready in time") + + def stop(self): + """Stop the HTTP server if it's running.""" + if self._process is not None: + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + self._process = None + + +@pytest.fixture +def unused_port(): + """Returns an unused port number on localhost.""" + return find_unused_port() + + +@pytest.fixture +def unused_port_server(unused_port): + """ + Returns a StaticServer instance that can start an HTTP server on an unused port. + The server automatically stops at the end of the test function. + """ + server = StaticServer(unused_port) + yield server + server.stop() diff --git a/tests/test_quickjs.py b/tests/test_quickjs.py new file mode 100644 index 00000000..375921b3 --- /dev/null +++ b/tests/test_quickjs.py @@ -0,0 +1,404 @@ +""" +Playwright tests for quickjs.html +Tests JavaScript code execution using QuickJS WebAssembly +""" + +import pathlib +import pytest +from playwright.sync_api import Page, expect + + +test_dir = pathlib.Path(__file__).parent.absolute() +root = test_dir.parent.absolute() + + +# Check if CDN is reachable (tests may be running in isolated environment) +def cdn_is_reachable(): + import urllib.request + import urllib.error + try: + urllib.request.urlopen('https://cdn.jsdelivr.net/', timeout=5) + return True + except (urllib.error.URLError, OSError): + return False + + +# Skip QuickJS runtime tests if CDN is not reachable +requires_cdn = pytest.mark.skipif( + not cdn_is_reachable(), + reason="CDN not reachable - tests require network access to load QuickJS" +) + + +def test_page_loads(page: Page, unused_port_server): + """Test that the page loads successfully""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Check title + expect(page).to_have_title("QuickJS Code Executor") + + # Check main heading + heading = page.locator("h1") + expect(heading).to_have_text("QuickJS Code Executor") + + +@requires_cdn +def test_initialization(page: Page, unused_port_server): + """Test that QuickJS initializes successfully""" + # Capture console logs for debugging + console_logs = [] + page.on("console", lambda msg: console_logs.append(f"{msg.type}: {msg.text}")) + + errors = [] + page.on("pageerror", lambda exc: errors.append(str(exc))) + + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization - button should change from "Initializing..." to "Run Code" + run_btn = page.locator("#run-btn") + + try: + expect(run_btn).to_have_text("Run Code", timeout=30000) + except AssertionError: + print("Console logs:", console_logs) + print("Errors:", errors) + raise + + expect(run_btn).to_be_enabled() + + +@requires_cdn +def test_simple_execution(page: Page, unused_port_server): + """Test executing simple JavaScript code""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + run_btn = page.locator("#run-btn") + expect(run_btn).to_have_text("Run Code", timeout=30000) + + # Enter simple code + page.locator("#code-input").fill("1 + 2") + + # Click run + run_btn.click() + + # Wait for output section to be visible + output_section = page.locator("#output-section") + expect(output_section).to_have_class("visible", timeout=5000) + + # Check output shows result + output = page.locator("#output") + expect(output).to_contain_text("3") + + +@requires_cdn +def test_console_log(page: Page, unused_port_server): + """Test console.log output""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + run_btn = page.locator("#run-btn") + expect(run_btn).to_have_text("Run Code", timeout=30000) + + # Enter code with console.log + page.locator("#code-input").fill("console.log('Hello, World!')") + + # Click run + run_btn.click() + + # Check output + output = page.locator("#output") + expect(output).to_contain_text("Hello, World!", timeout=5000) + + +@requires_cdn +def test_multiple_console_logs(page: Page, unused_port_server): + """Test multiple console.log statements""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + run_btn = page.locator("#run-btn") + expect(run_btn).to_have_text("Run Code", timeout=30000) + + # Enter code with multiple console.log statements + code = """console.log('Line 1'); +console.log('Line 2'); +console.log('Line 3');""" + page.locator("#code-input").fill(code) + + # Click run + run_btn.click() + + # Check output contains all lines + output = page.locator("#output") + expect(output).to_contain_text("Line 1", timeout=5000) + expect(output).to_contain_text("Line 2") + expect(output).to_contain_text("Line 3") + + +@requires_cdn +def test_error_handling(page: Page, unused_port_server): + """Test that JavaScript errors are displayed""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + run_btn = page.locator("#run-btn") + expect(run_btn).to_have_text("Run Code", timeout=30000) + + # Enter code with syntax error + page.locator("#code-input").fill("this is not valid javascript") + + # Click run + run_btn.click() + + # Check output shows error + output = page.locator("#output") + expect(output).to_have_class("error-output", timeout=5000) + expect(output).to_contain_text("Error") + + +@requires_cdn +def test_url_hash_update(page: Page, unused_port_server): + """Test that URL hash is updated after execution""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + run_btn = page.locator("#run-btn") + expect(run_btn).to_have_text("Run Code", timeout=30000) + + # Enter code + code = "console.log('test')" + page.locator("#code-input").fill(code) + + # Click run + run_btn.click() + + # Wait for output + expect(page.locator("#output-section")).to_have_class("visible", timeout=5000) + + # Check URL hash contains the code + url = page.url + assert "#" in url + # The hash should contain URL-encoded version of the code + assert "console.log" in url or "console" in url + + +@requires_cdn +def test_url_hash_load_and_execute(page: Page, unused_port_server): + """Test that loading page with hash populates and executes code""" + unused_port_server.start(root) + + # Load page with code in hash + code = "console.log('from hash')" + encoded_code = code.replace("'", "%27").replace("(", "%28").replace(")", "%29") + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html#{encoded_code}") + + # Wait for initialization and execution + run_btn = page.locator("#run-btn") + expect(run_btn).to_have_text("Run Code", timeout=30000) + + # Check that code was populated + code_input = page.locator("#code-input") + expect(code_input).to_have_value(code, timeout=5000) + + # Check that code was executed and output is shown + output = page.locator("#output") + expect(output).to_contain_text("from hash", timeout=5000) + + +@requires_cdn +def test_clear_button(page: Page, unused_port_server): + """Test that clear button works""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Enter code and run + page.locator("#code-input").fill("console.log('test')") + page.locator("#run-btn").click() + + # Wait for output + expect(page.locator("#output-section")).to_have_class("visible", timeout=5000) + + # Click clear + page.locator("#clear-btn").click() + + # Check that input is cleared + expect(page.locator("#code-input")).to_have_value("") + + # Check that output is hidden + expect(page.locator("#output-section")).not_to_have_class("visible") + + +@requires_cdn +def test_keyboard_shortcut(page: Page, unused_port_server): + """Test Ctrl+Enter keyboard shortcut to run code""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Enter code + code_input = page.locator("#code-input") + code_input.fill("console.log('shortcut test')") + + # Focus the textarea and press Ctrl+Enter + code_input.focus() + page.keyboard.press("Control+Enter") + + # Check output + output = page.locator("#output") + expect(output).to_contain_text("shortcut test", timeout=5000) + + +@requires_cdn +def test_execution_time_displayed(page: Page, unused_port_server): + """Test that execution time is displayed""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Enter and run code + page.locator("#code-input").fill("1 + 1") + page.locator("#run-btn").click() + + # Wait for output + expect(page.locator("#output-section")).to_have_class("visible", timeout=5000) + + # Check execution time is shown + execution_time = page.locator("#execution-time") + expect(execution_time).to_contain_text("Execution time:") + expect(execution_time).to_contain_text("ms") + + +@requires_cdn +def test_copy_button(page: Page, unused_port_server): + """Test that copy button works""" + unused_port_server.start(root) + + # Grant clipboard permissions + context = page.context + context.grant_permissions(["clipboard-read", "clipboard-write"]) + + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Enter and run code + page.locator("#code-input").fill("console.log('copy me')") + page.locator("#run-btn").click() + + # Wait for output + expect(page.locator("#output-section")).to_have_class("visible", timeout=5000) + + # Click copy button + copy_btn = page.locator("#copy-btn") + copy_btn.click() + + # Check button text changes to "Copied!" + expect(copy_btn).to_have_text("Copied!") + + +@requires_cdn +def test_function_execution(page: Page, unused_port_server): + """Test executing code with function definitions""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Enter code with function + code = """function add(a, b) { + return a + b; +} +console.log(add(2, 3));""" + page.locator("#code-input").fill(code) + page.locator("#run-btn").click() + + # Check output + output = page.locator("#output") + expect(output).to_contain_text("5", timeout=5000) + + +@requires_cdn +def test_return_value_display(page: Page, unused_port_server): + """Test that return values are displayed with => prefix""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Enter expression that returns a value + page.locator("#code-input").fill("42 * 2") + page.locator("#run-btn").click() + + # Check output shows return value with => + output = page.locator("#output") + expect(output).to_contain_text("=> 84", timeout=5000) + + +@requires_cdn +def test_object_output(page: Page, unused_port_server): + """Test that objects are displayed as JSON""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Enter code that logs an object + page.locator("#code-input").fill("console.log({name: 'test', value: 123})") + page.locator("#run-btn").click() + + # Check output contains object properties + output = page.locator("#output") + expect(output).to_contain_text("name", timeout=5000) + expect(output).to_contain_text("test") + expect(output).to_contain_text("123") + + +def test_mobile_responsive(page: Page, unused_port_server): + """Test mobile responsiveness""" + page.set_viewport_size({"width": 375, "height": 667}) + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Check elements are visible + expect(page.locator("h1")).to_be_visible() + expect(page.locator("#code-input")).to_be_visible() + expect(page.locator("#run-btn")).to_be_visible() + expect(page.locator("#clear-btn")).to_be_visible() + + +@requires_cdn +def test_empty_code_validation(page: Page, unused_port_server): + """Test validation when trying to run empty code""" + unused_port_server.start(root) + page.goto(f"http://127.0.0.1:{unused_port_server.port}/quickjs.html") + + # Wait for initialization + expect(page.locator("#run-btn")).to_have_text("Run Code", timeout=30000) + + # Clear the placeholder text and try to run empty code + page.locator("#code-input").fill("") + page.locator("#run-btn").click() + + # Check error message is shown + status = page.locator("#status") + expect(status).to_have_class("visible error", timeout=5000) + expect(status).to_contain_text("enter some code")