pyjs-examples 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyjs_examples/__init__.py +1 -0
- pyjs_examples/calculator/README.md +55 -0
- pyjs_examples/calculator/__init__.py +1 -0
- pyjs_examples/calculator/__main__.py +9 -0
- pyjs_examples/calculator/calculator.py +325 -0
- pyjs_examples/calculator/tests/test_browser_calculator.py +67 -0
- pyjs_examples/calculator/tests/test_calculator.py +72 -0
- pyjs_examples/catalog.py +1433 -0
- pyjs_examples/chatroom/README.md +50 -0
- pyjs_examples/chatroom/__init__.py +1 -0
- pyjs_examples/chatroom/__main__.py +12 -0
- pyjs_examples/chatroom/chat_server.py +156 -0
- pyjs_examples/chatroom/chatroom.py +704 -0
- pyjs_examples/chatroom/tests/test_browser_chatroom.py +59 -0
- pyjs_examples/chatroom/tests/test_chat_server.py +53 -0
- pyjs_examples/chatroom/visual_test.py +156 -0
- pyjs_examples/counter.py +73 -0
- pyjs_examples/kanban/README.md +31 -0
- pyjs_examples/kanban/__init__.py +1 -0
- pyjs_examples/kanban/__main__.py +8 -0
- pyjs_examples/kanban/kanban.py +581 -0
- pyjs_examples/kanban/tests/renders/kanban-board.png +0 -0
- pyjs_examples/kanban/tests/test_browser_kanban.py +150 -0
- pyjs_examples/kanban/tests/test_kanban.py +38 -0
- pyjs_examples/kanban/visual_test.py +108 -0
- pyjs_examples/nativescript_counter/README.md +31 -0
- pyjs_examples/nativescript_counter/__init__.py +5 -0
- pyjs_examples/nativescript_counter/__main__.py +17 -0
- pyjs_examples/nativescript_counter/counter.py +54 -0
- pyjs_examples/nativescript_counter/tests/test_android_counter.py +31 -0
- pyjs_examples/nativescript_counter/tests/test_counter.py +11 -0
- pyjs_examples/samples/__init__.py +1 -0
- pyjs_examples/samples/state_counter/README.md +19 -0
- pyjs_examples/samples/state_counter/__init__.py +1 -0
- pyjs_examples/samples/state_counter/__main__.py +3 -0
- pyjs_examples/samples/state_counter/state_counter.py +60 -0
- pyjs_examples/samples/state_counter/tests/test_browser_state_counter.py +18 -0
- pyjs_examples/samples/state_counter/tests/test_state_counter.py +17 -0
- pyjs_examples/samples/ui_dialog/README.md +19 -0
- pyjs_examples/samples/ui_dialog/__init__.py +1 -0
- pyjs_examples/samples/ui_dialog/__main__.py +3 -0
- pyjs_examples/samples/ui_dialog/tests/test_browser_ui_dialog.py +21 -0
- pyjs_examples/samples/ui_dialog/tests/test_ui_dialog.py +10 -0
- pyjs_examples/samples/ui_dialog/ui_dialog.py +65 -0
- pyjs_examples/space_invaders/README.md +45 -0
- pyjs_examples/space_invaders/__init__.py +1 -0
- pyjs_examples/space_invaders/__main__.py +3 -0
- pyjs_examples/space_invaders/space_invaders.py +1025 -0
- pyjs_examples/space_invaders/tests/renders/action_scene.png +0 -0
- pyjs_examples/space_invaders/tests/renders/initial_wave.png +0 -0
- pyjs_examples/space_invaders/tests/test_browser_game.py +86 -0
- pyjs_examples/space_invaders/tests/test_game.py +83 -0
- pyjs_examples/todomvc/README.md +50 -0
- pyjs_examples/todomvc/__init__.py +1 -0
- pyjs_examples/todomvc/__main__.py +12 -0
- pyjs_examples/todomvc/base.css +141 -0
- pyjs_examples/todomvc/index.css +393 -0
- pyjs_examples/todomvc/tests/test_browser_todomvc.py +145 -0
- pyjs_examples/todomvc/tests/test_todomvc.py +84 -0
- pyjs_examples/todomvc/todomvc.py +426 -0
- pyjs_examples/workspace/README.md +25 -0
- pyjs_examples/workspace/__init__.py +1 -0
- pyjs_examples/workspace/__main__.py +3 -0
- pyjs_examples/workspace/tests/test_browser_workspace.py +109 -0
- pyjs_examples/workspace/tests/test_workspace.py +27 -0
- pyjs_examples/workspace/workspace.py +749 -0
- pyjs_examples-0.0.1.dist-info/METADATA +71 -0
- pyjs_examples-0.0.1.dist-info/RECORD +71 -0
- pyjs_examples-0.0.1.dist-info/WHEEL +5 -0
- pyjs_examples-0.0.1.dist-info/entry_points.txt +11 -0
- pyjs_examples-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pyjs import js
|
|
3
|
+
from pyjs.testing.web import mount, require_element
|
|
4
|
+
from pyjs.web.dom import sessionStorage
|
|
5
|
+
from pyjs.web.events import Event
|
|
6
|
+
from pyjs_examples.chatroom.chatroom import ChatMessage, ChatRoomApp, ChatSnapshot
|
|
7
|
+
|
|
8
|
+
pytestmark = pytest.mark.pyjs_web
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@js
|
|
12
|
+
def mounted_chatroom() -> ChatRoomApp:
|
|
13
|
+
app = ChatRoomApp()
|
|
14
|
+
mount(app)
|
|
15
|
+
return app
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@js
|
|
19
|
+
def test_chatroom_prompts_for_a_per_tab_display_name():
|
|
20
|
+
sessionStorage.removeItem("pyjs-chatroom-display-name")
|
|
21
|
+
app = mounted_chatroom()
|
|
22
|
+
|
|
23
|
+
assert not app.sign_in.classList.contains("hidden")
|
|
24
|
+
assert app.workspace.classList.contains("hidden")
|
|
25
|
+
|
|
26
|
+
app.name_input.value = "Alice"
|
|
27
|
+
app.name_form.dispatchEvent(Event("submit"))
|
|
28
|
+
|
|
29
|
+
assert sessionStorage.getItem("pyjs-chatroom-display-name") == "Alice"
|
|
30
|
+
assert app.getAttribute("name") == "Alice"
|
|
31
|
+
assert app.sign_in.classList.contains("hidden")
|
|
32
|
+
assert not app.workspace.classList.contains("hidden")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@js
|
|
36
|
+
def test_chatroom_renders_rooms_messages_and_presence_from_a_snapshot():
|
|
37
|
+
sessionStorage.setItem("pyjs-chatroom-display-name", "Alice")
|
|
38
|
+
app = mounted_chatroom()
|
|
39
|
+
message = ChatMessage()
|
|
40
|
+
message.id = 1
|
|
41
|
+
message.room = "design"
|
|
42
|
+
message.sender = "Bob"
|
|
43
|
+
message.text = "The new navigation is ready."
|
|
44
|
+
message.time = "09:42"
|
|
45
|
+
snapshot = ChatSnapshot()
|
|
46
|
+
snapshot.type = "snapshot"
|
|
47
|
+
snapshot.room = "design"
|
|
48
|
+
snapshot.rooms = ["general", "design"]
|
|
49
|
+
snapshot.messages = [message]
|
|
50
|
+
snapshot.users = ["Alice", "Bob"]
|
|
51
|
+
|
|
52
|
+
app.receive(snapshot)
|
|
53
|
+
|
|
54
|
+
assert app.current_room.textContent == "# design"
|
|
55
|
+
assert len(app.room_list.querySelectorAll("[data-room]")) == 2
|
|
56
|
+
assert len(app.message_list.querySelectorAll("[data-message-id]")) == 1
|
|
57
|
+
assert require_element(app.message_list, "p").textContent == "The new navigation is ready."
|
|
58
|
+
assert len(app.member_list.querySelectorAll("[data-user]")) == 2
|
|
59
|
+
assert app.member_count.textContent == "2 people"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from pyjs_examples.chatroom.chat_server import ChatServer
|
|
6
|
+
|
|
7
|
+
pytestmark = pytest.mark.pyjs_python
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeConnection:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.messages = []
|
|
13
|
+
|
|
14
|
+
async def send(self, message):
|
|
15
|
+
self.messages.append(json.loads(message))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_room_creation_presence_and_messages_are_broadcast():
|
|
19
|
+
async def exercise_chat():
|
|
20
|
+
server = ChatServer()
|
|
21
|
+
alice = FakeConnection()
|
|
22
|
+
bob = FakeConnection()
|
|
23
|
+
|
|
24
|
+
await server.dispatch(alice, {"type": "join", "name": "Alice", "room": "general"})
|
|
25
|
+
await server.dispatch(bob, {"type": "join", "name": "Bob", "room": "general"})
|
|
26
|
+
await server.dispatch(alice, {"type": "create_room", "room": "Design Team"})
|
|
27
|
+
await server.dispatch(bob, {"type": "switch_room", "room": "design-team"})
|
|
28
|
+
await server.dispatch(alice, {"type": "message", "text": "Welcome to design!"})
|
|
29
|
+
await server.dispatch(bob, {"type": "message", "text": "Glad to be here."})
|
|
30
|
+
|
|
31
|
+
return server, alice.messages[-1], bob.messages[-1]
|
|
32
|
+
|
|
33
|
+
server, alice_snapshot, bob_snapshot = asyncio.run(exercise_chat())
|
|
34
|
+
|
|
35
|
+
assert list(server.rooms) == ["general", "design-team"]
|
|
36
|
+
assert alice_snapshot["room"] == "design-team"
|
|
37
|
+
assert bob_snapshot["room"] == "design-team"
|
|
38
|
+
assert alice_snapshot["users"] == ["Alice", "Bob"]
|
|
39
|
+
assert [message["text"] for message in bob_snapshot["messages"]] == [
|
|
40
|
+
"Welcome to design!",
|
|
41
|
+
"Glad to be here.",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_chat_input_is_validated():
|
|
46
|
+
async def send_invalid_message():
|
|
47
|
+
server = ChatServer()
|
|
48
|
+
connection = FakeConnection()
|
|
49
|
+
await server.dispatch(connection, {"type": "join", "name": "Alice", "room": "general"})
|
|
50
|
+
await server.dispatch(connection, {"type": "message", "text": 42})
|
|
51
|
+
|
|
52
|
+
with pytest.raises(ValueError, match="message text must be a string"):
|
|
53
|
+
asyncio.run(send_invalid_message())
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pyjs.tooling import stop_process
|
|
12
|
+
from pyjs.tooling.browser import Browser, BrowserPage, free_port, wait_for_http
|
|
13
|
+
|
|
14
|
+
PROJECT_ROOT = Path.cwd()
|
|
15
|
+
OUTPUT = PROJECT_ROOT / "screenshots"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def server_command() -> list[str | Path]:
|
|
19
|
+
local_application = PROJECT_ROOT / "chatroom.py"
|
|
20
|
+
if local_application.is_file():
|
|
21
|
+
return [sys.executable, local_application]
|
|
22
|
+
return [sys.executable, "-m", "pyjs_examples.chatroom"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def fill_and_submit(page: BrowserPage, test_id: str, value: str) -> None:
|
|
26
|
+
selector = f'[data-testid="{test_id}"]'
|
|
27
|
+
await page.fill(selector, value)
|
|
28
|
+
await page.submit(selector)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def state(page: BrowserPage) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"name": await page.attribute("chat-room-app", "name"),
|
|
34
|
+
"room": await page.attribute("chat-room-app", "room"),
|
|
35
|
+
"connection": await page.attribute("chat-room-app", "connection"),
|
|
36
|
+
"rooms": await page.attributes("[data-room]", "data-room"),
|
|
37
|
+
"users": await page.attributes("[data-user]", "data-user"),
|
|
38
|
+
"messages": await page.texts("[data-message-id] p"),
|
|
39
|
+
"memberCount": await page.text('[data-testid="member-count"]'),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verify_state(value: dict, expected_name: str) -> None:
|
|
44
|
+
expected = {
|
|
45
|
+
"name": expected_name,
|
|
46
|
+
"room": "product-design",
|
|
47
|
+
"connection": "connected",
|
|
48
|
+
"rooms": ["general", "product-design"],
|
|
49
|
+
"users": ["Alice", "Bob"],
|
|
50
|
+
"messages": [
|
|
51
|
+
"Welcome to the new room, Bob!",
|
|
52
|
+
"Thanks Alice — real-time chat is working.",
|
|
53
|
+
],
|
|
54
|
+
"memberCount": "2 people",
|
|
55
|
+
}
|
|
56
|
+
if value != expected:
|
|
57
|
+
raise RuntimeError(f"Unexpected {expected_name} state: {json.dumps(value)}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def exercise(browser: Browser, alice: BrowserPage, bob: BrowserPage) -> dict:
|
|
61
|
+
await alice.wait_for_selector('[data-testid="name-input"]', "Alice's chatroom")
|
|
62
|
+
await bob.wait_for_selector('[data-testid="name-input"]', "Bob's chatroom")
|
|
63
|
+
|
|
64
|
+
await fill_and_submit(alice, "name-input", "Alice")
|
|
65
|
+
await alice.wait_for_attribute(
|
|
66
|
+
"chat-room-app",
|
|
67
|
+
"connection",
|
|
68
|
+
"connected",
|
|
69
|
+
"Alice's connection",
|
|
70
|
+
)
|
|
71
|
+
await fill_and_submit(bob, "name-input", "Bob")
|
|
72
|
+
await bob.wait_for_attribute(
|
|
73
|
+
"chat-room-app",
|
|
74
|
+
"connection",
|
|
75
|
+
"connected",
|
|
76
|
+
"Bob's connection",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
await fill_and_submit(alice, "room-input", "product-design")
|
|
80
|
+
await bob.wait_for_selector(
|
|
81
|
+
'[data-room="product-design"]',
|
|
82
|
+
"the new room in Bob's tab",
|
|
83
|
+
)
|
|
84
|
+
await bob.click('[data-room="product-design"]')
|
|
85
|
+
await alice.wait_for_count("[data-user]", 2, "both room members")
|
|
86
|
+
|
|
87
|
+
await fill_and_submit(alice, "message-input", "Welcome to the new room, Bob!")
|
|
88
|
+
await bob.wait_for_text(
|
|
89
|
+
'[data-testid="message-list"]',
|
|
90
|
+
"Welcome",
|
|
91
|
+
"Alice's message in Bob's tab",
|
|
92
|
+
)
|
|
93
|
+
await fill_and_submit(bob, "message-input", "Thanks Alice — real-time chat is working.")
|
|
94
|
+
await alice.wait_for_text(
|
|
95
|
+
'[data-testid="message-list"]',
|
|
96
|
+
"Thanks Alice",
|
|
97
|
+
"Bob's message in Alice's tab",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
alice_state = await state(alice)
|
|
101
|
+
bob_state = await state(bob)
|
|
102
|
+
verify_state(alice_state, "Alice")
|
|
103
|
+
verify_state(bob_state, "Bob")
|
|
104
|
+
await alice.screenshot(OUTPUT / "chatroom-alice.png")
|
|
105
|
+
await bob.screenshot(OUTPUT / "chatroom-bob.png")
|
|
106
|
+
|
|
107
|
+
await browser.close_page(bob)
|
|
108
|
+
await alice.wait_for_count("[data-user]", 1, "Bob's departure in Alice's tab")
|
|
109
|
+
return {
|
|
110
|
+
"alice": alice_state,
|
|
111
|
+
"bob": bob_state,
|
|
112
|
+
"remainingUsers": await alice.attributes("[data-user]", "data-user"),
|
|
113
|
+
"errors": alice.exceptions + bob.exceptions,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def main() -> None:
|
|
118
|
+
app_port = free_port()
|
|
119
|
+
app_url = f"http://127.0.0.1:{app_port}/"
|
|
120
|
+
environment = os.environ.copy()
|
|
121
|
+
environment["PYJS_PORT"] = str(app_port)
|
|
122
|
+
with tempfile.TemporaryFile(mode="w+") as server_log:
|
|
123
|
+
server = subprocess.Popen(
|
|
124
|
+
server_command(),
|
|
125
|
+
cwd=PROJECT_ROOT,
|
|
126
|
+
env=environment,
|
|
127
|
+
stdout=server_log,
|
|
128
|
+
stderr=subprocess.STDOUT,
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
await wait_for_http(app_url, timeout=20)
|
|
132
|
+
async with Browser() as browser:
|
|
133
|
+
alice = await browser.new_page(app_url)
|
|
134
|
+
bob = await browser.new_page(app_url)
|
|
135
|
+
await alice.set_viewport(1440, 900)
|
|
136
|
+
await bob.set_viewport(1440, 900)
|
|
137
|
+
result = await exercise(browser, alice, bob)
|
|
138
|
+
if result["errors"]:
|
|
139
|
+
raise RuntimeError(f"Browser errors: {'; '.join(result['errors'])}")
|
|
140
|
+
OUTPUT.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
(OUTPUT / "chatroom.json").write_text(
|
|
142
|
+
json.dumps(result, indent=2) + "\n",
|
|
143
|
+
encoding="utf-8",
|
|
144
|
+
)
|
|
145
|
+
print(json.dumps(result, indent=2))
|
|
146
|
+
except BaseException:
|
|
147
|
+
stop_process(server)
|
|
148
|
+
server_log.seek(0)
|
|
149
|
+
sys.stderr.write(server_log.read())
|
|
150
|
+
raise
|
|
151
|
+
finally:
|
|
152
|
+
stop_process(server)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
asyncio.run(main())
|
pyjs_examples/counter.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""A compact reactive counter demonstrating state, effects, and DOM event handling."""
|
|
2
|
+
|
|
3
|
+
from pyjs import effect, js, state
|
|
4
|
+
from pyjs.web.domx import CustomElement, ref, tag, tw
|
|
5
|
+
from pyjs.web.events import Event
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@js
|
|
9
|
+
class CounterApp(CustomElement):
|
|
10
|
+
count = state(0)
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.counter_text = ref(tag("div", tw("text-4xl font-mono text-blue-600")))
|
|
15
|
+
self.sub_button = tag(
|
|
16
|
+
"button", tw("px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg"), "-"
|
|
17
|
+
)
|
|
18
|
+
self.sub_button.addEventListener("click", self.decrement)
|
|
19
|
+
self.add_button = tag(
|
|
20
|
+
"button", tw("px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg"), "+"
|
|
21
|
+
)
|
|
22
|
+
self.add_button.addEventListener("click", self.increment)
|
|
23
|
+
tag(
|
|
24
|
+
self,
|
|
25
|
+
tag(
|
|
26
|
+
"body",
|
|
27
|
+
tw("bg-gray-100 flex items-center justify-center h-screen"),
|
|
28
|
+
tag(
|
|
29
|
+
"div",
|
|
30
|
+
tw("bg-white shadow-md rounded-xl p-6 w-80 text-center space-y-6"),
|
|
31
|
+
tag("h1", tw("text-2xl font-bold"), "Counter"),
|
|
32
|
+
self.counter_text,
|
|
33
|
+
tag(
|
|
34
|
+
"div",
|
|
35
|
+
tw("flex justify-center space-x-4"),
|
|
36
|
+
self.sub_button,
|
|
37
|
+
self.add_button,
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@effect(count)
|
|
44
|
+
def render_count(self):
|
|
45
|
+
self.counter_text.textContent = str(self.count)
|
|
46
|
+
|
|
47
|
+
def decrement(self, e: Event):
|
|
48
|
+
self.count -= 1
|
|
49
|
+
|
|
50
|
+
def increment(self, e: Event):
|
|
51
|
+
self.count += 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
return CounterApp()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@js
|
|
59
|
+
def test_counter_updates_its_state():
|
|
60
|
+
app = CounterApp()
|
|
61
|
+
|
|
62
|
+
app.increment(Event("click"))
|
|
63
|
+
app.increment(Event("click"))
|
|
64
|
+
app.decrement(Event("click"))
|
|
65
|
+
|
|
66
|
+
assert app.count == 1
|
|
67
|
+
assert app.counter_text.textContent == "1"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
from pyjs.server import serve
|
|
72
|
+
|
|
73
|
+
serve(__file__)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Kanban Board Example
|
|
2
|
+
|
|
3
|
+
This example implements a polished board with native HTML drag-and-drop. Cards can move between
|
|
4
|
+
columns, reorder at the pointer midpoint, or use visible arrow controls for the same behavior. The
|
|
5
|
+
DOM is the source of truth for card order and column counts.
|
|
6
|
+
|
|
7
|
+
## Run the board
|
|
8
|
+
|
|
9
|
+
From the repository root:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
.venv/bin/python -m pyjs_examples.kanban
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Open the clickable URL printed by the server.
|
|
16
|
+
|
|
17
|
+
## Run the tests
|
|
18
|
+
|
|
19
|
+
The focused browser suite dispatches the complete drag event lifecycle and covers midpoint
|
|
20
|
+
placement, invalid drops, count updates, accessibility controls, and the stored visual baseline:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
.venv/bin/python -m pytest -q examples/kanban/tests --pyjs-target=web --pyjs-browser=managed
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The end-to-end harness drives a real mouse through Chrome and writes its screenshot and structured
|
|
27
|
+
state to the current directory's `screenshots/` folder:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
.venv/bin/python examples/kanban/visual_test.py
|
|
31
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""A native drag-and-drop Kanban board built with PyJS custom elements."""
|