Skip to content

Commit 8ea62a3

Browse files
UI tests with Playwright (#413)
Co-authored-by: Philip Meier <github.pmeier@posteo.de>
1 parent 6ef051a commit 8ea62a3

File tree

9 files changed

+280
-51
lines changed

9 files changed

+280
-51
lines changed

.github/actions/setup-env/action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ runs:
5858
shell: bash -el {0}
5959
run: mamba install --yes --channel conda-forge redis-server
6060

61+
- name: Install playwright
62+
shell: bash -el {0}
63+
run: playwright install
64+
6165
- name: Install ragna
6266
shell: bash -el {0}
6367
run: |

.github/workflows/test.yml

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,76 @@ jobs:
6868

6969
- name: Run unit tests
7070
id: tests
71-
run: pytest --junit-xml=test-results.xml --durations=25
71+
run: |
72+
pytest \
73+
--ignore tests/deploy/ui \
74+
--junit-xml=test-results.xml \
75+
--durations=25
7276
7377
- name: Surface failing tests
7478
if: steps.tests.outcome != 'success'
7579
uses: pmeier/pytest-results-action@v0.3.0
7680
with:
7781
path: test-results.xml
82+
83+
pytest-ui:
84+
strategy:
85+
matrix:
86+
os:
87+
- ubuntu-latest
88+
- windows-latest
89+
- macos-latest
90+
browser:
91+
- chromium
92+
- firefox
93+
python-version:
94+
- "3.9"
95+
- "3.10"
96+
- "3.11"
97+
exclude:
98+
- python-version: "3.10"
99+
os: windows-latest
100+
- python-version: "3.11"
101+
os: windows-latest
102+
- python-version: "3.10"
103+
os: macos-latest
104+
- python-version: "3.11"
105+
os: macos-latest
106+
include:
107+
- browser: webkit
108+
os: macos-latest
109+
python-version: "3.9"
110+
111+
fail-fast: false
112+
113+
runs-on: ${{ matrix.os }}
114+
115+
defaults:
116+
run:
117+
shell: bash -el {0}
118+
119+
steps:
120+
- name: Checkout repository
121+
uses: actions/checkout@v4
122+
with:
123+
fetch-depth: 0
124+
125+
- name: Setup environment
126+
uses: ./.github/actions/setup-env
127+
with:
128+
python-version: ${{ matrix.python-version }}
129+
130+
- name: Run unit tests
131+
id: tests
132+
run: |
133+
pytest tests/deploy/ui \
134+
--browser ${{ matrix.browser }} \
135+
--video=retain-on-failure
136+
137+
- name: Upload playwright video
138+
if: failure()
139+
uses: actions/upload-artifact@v4
140+
with:
141+
name:
142+
playwright-${{ matrix.os }}-${{ matrix.python-version}}-${{ github.run_id }}
143+
path: test-results

environment-dev.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies:
1010
- pytest >=6
1111
- pytest-mock
1212
- pytest-asyncio
13+
- pytest-playwright
1314
- mypy ==1.10.0
1415
- pre-commit
1516
- types-aiofiles

tests/deploy/api/test_batch_endpoints.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33

44
from ragna.deploy import Config
55
from ragna.deploy._api import app
6-
7-
from .utils import authenticate
6+
from tests.deploy.utils import authenticate_with_api
87

98

109
def test_batch_sequential_upload_equivalence(tmp_local_root):
@@ -23,7 +22,7 @@ def test_batch_sequential_upload_equivalence(tmp_local_root):
2322
with TestClient(
2423
app(config=Config(), ignore_unavailable_components=False)
2524
) as client:
26-
authenticate(client)
25+
authenticate_with_api(client)
2726

2827
document1_upload = (
2928
client.post("/document", json={"name": document_path1.name})

tests/deploy/api/test_components.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
from ragna.core import RagnaException
77
from ragna.deploy import Config
88
from ragna.deploy._api import app
9-
10-
from .utils import authenticate
9+
from tests.deploy.utils import authenticate_with_api
1110

1211

1312
@pytest.mark.parametrize("ignore_unavailable_components", [True, False])
@@ -27,7 +26,7 @@ def test_ignore_unavailable_components(ignore_unavailable_components):
2726
ignore_unavailable_components=ignore_unavailable_components,
2827
)
2928
) as client:
30-
authenticate(client)
29+
authenticate_with_api(client)
3130

3231
components = client.get("/components").raise_for_status().json()
3332
assert [assistant["title"] for assistant in components["assistants"]] == [
@@ -66,7 +65,7 @@ def test_unknown_component(tmp_local_root):
6665
with TestClient(
6766
app(config=Config(), ignore_unavailable_components=False)
6867
) as client:
69-
authenticate(client)
68+
authenticate_with_api(client)
7069

7170
document_upload = (
7271
client.post("/document", json={"name": document_path.name})

tests/deploy/api/test_e2e.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,11 @@
11
import json
2-
import time
32

43
import pytest
54
from fastapi.testclient import TestClient
65

7-
from ragna.assistants import RagnaDemoAssistant
86
from ragna.deploy import Config
97
from ragna.deploy._api import app
10-
11-
from .utils import authenticate
12-
13-
14-
class TestAssistant(RagnaDemoAssistant):
15-
def answer(self, prompt, sources, *, multiple_answer_chunks: bool):
16-
# Simulate a "real" assistant through a small delay. See
17-
# https://github.com/Quansight/ragna/pull/401#issuecomment-2095851440
18-
# for why this is needed.
19-
time.sleep(1e-3)
20-
content = next(super().answer(prompt, sources))
21-
22-
if multiple_answer_chunks:
23-
for chunk in content.split(" "):
24-
yield f"{chunk} "
25-
else:
26-
yield content
8+
from tests.deploy.utils import TestAssistant, authenticate_with_api
279

2810

2911
@pytest.mark.parametrize("multiple_answer_chunks", [True, False])
@@ -38,7 +20,7 @@ def test_e2e(tmp_local_root, multiple_answer_chunks, stream_answer):
3820
file.write("!\n")
3921

4022
with TestClient(app(config=config, ignore_unavailable_components=False)) as client:
41-
authenticate(client)
23+
authenticate_with_api(client)
4224

4325
assert client.get("/chats").raise_for_status().json() == []
4426

tests/deploy/api/utils.py

Lines changed: 0 additions & 23 deletions
This file was deleted.

tests/deploy/ui/test_ui.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import socket
2+
import subprocess
3+
import sys
4+
import time
5+
6+
import httpx
7+
import panel as pn
8+
import pytest
9+
from playwright.sync_api import Page, expect
10+
11+
from ragna._utils import timeout_after
12+
from ragna.deploy import Config
13+
from tests.deploy.utils import TestAssistant
14+
15+
16+
def get_available_port():
17+
with socket.socket() as s:
18+
s.bind(("", 0))
19+
return s.getsockname()[1]
20+
21+
22+
@pytest.fixture
23+
def config(
24+
tmp_local_root,
25+
):
26+
config = Config(
27+
local_root=tmp_local_root,
28+
assistants=[TestAssistant],
29+
ui=dict(port=get_available_port()),
30+
api=dict(port=get_available_port()),
31+
)
32+
path = tmp_local_root / "ragna.toml"
33+
config.to_file(path)
34+
return config
35+
36+
37+
class Server:
38+
def __init__(self, config):
39+
self.config = config
40+
self.base_url = f"http://{config.ui.hostname}:{config.ui.port}"
41+
42+
def server_up(self):
43+
try:
44+
return httpx.get(self.base_url).is_success
45+
except httpx.ConnectError:
46+
return False
47+
48+
@timeout_after(60)
49+
def start(self):
50+
self.proc = subprocess.Popen(
51+
[
52+
sys.executable,
53+
"-m",
54+
"ragna",
55+
"ui",
56+
"--config",
57+
self.config.local_root / "ragna.toml",
58+
"--start-api",
59+
"--ignore-unavailable-components",
60+
"--no-open-browser",
61+
],
62+
stdout=sys.stdout,
63+
stderr=sys.stderr,
64+
)
65+
66+
while not self.server_up():
67+
time.sleep(1)
68+
69+
def stop(self):
70+
self.proc.kill()
71+
pn.state.kill_all_servers()
72+
73+
def __enter__(self):
74+
self.start()
75+
return self
76+
77+
def __exit__(self, *args):
78+
self.stop()
79+
80+
81+
def test_health(config, page: Page) -> None:
82+
with Server(config) as server:
83+
health_url = f"{server.base_url}/health"
84+
response = page.goto(health_url)
85+
assert response.ok
86+
87+
88+
def test_start_chat(config, page: Page) -> None:
89+
with Server(config) as server:
90+
# Index page, no auth
91+
index_url = server.base_url
92+
page.goto(index_url)
93+
expect(page.get_by_role("button", name="Sign In")).to_be_visible()
94+
95+
# Authorize with no credentials
96+
page.get_by_role("button", name="Sign In").click()
97+
expect(page.get_by_role("button", name=" New Chat")).to_be_visible()
98+
99+
# expect auth token to be set
100+
cookies = page.context.cookies()
101+
assert len(cookies) == 1
102+
cookie = cookies[0]
103+
assert cookie.get("name") == "auth_token"
104+
auth_token = cookie.get("value")
105+
assert auth_token is not None
106+
107+
# New page button
108+
new_chat_button = page.get_by_role("button", name=" New Chat")
109+
expect(new_chat_button).to_be_visible()
110+
new_chat_button.click()
111+
112+
document_root = config.local_root / "documents"
113+
document_root.mkdir()
114+
document_name = "test.txt"
115+
document_path = document_root / document_name
116+
with open(document_path, "w") as file:
117+
file.write("!\n")
118+
119+
# File upload selector
120+
with page.expect_file_chooser() as fc_info:
121+
page.locator(".fileUpload").click()
122+
file_chooser = fc_info.value
123+
file_chooser.set_files(document_path)
124+
125+
# Upload document and expect to see it listed
126+
file_list = page.locator(".fileListContainer")
127+
expect(file_list.first).to_have_text(str(document_name))
128+
129+
chat_dialog = page.get_by_role("dialog")
130+
expect(chat_dialog).to_be_visible()
131+
start_chat_button = page.get_by_role("button", name="Start Conversation")
132+
expect(start_chat_button).to_be_visible()
133+
time.sleep(0.5) # hack while waiting for button to be fully clickable
134+
start_chat_button.click(delay=5)
135+
136+
chat_box_row = page.locator(".chat-interface-input-row")
137+
expect(chat_box_row).to_be_visible()
138+
139+
chat_box = chat_box_row.get_by_role("textbox")
140+
expect(chat_box).to_be_visible()
141+
142+
# Document should be in the database
143+
chats_url = f"http://{config.api.hostname}:{config.api.port}/chats"
144+
chats = httpx.get(
145+
chats_url, headers={"Authorization": f"Bearer {auth_token}"}
146+
).json()
147+
assert len(chats) == 1
148+
chat = chats[0]
149+
chat_documents = chat["metadata"]["documents"]
150+
assert len(chat_documents) == 1
151+
assert chat_documents[0]["name"] == document_name
152+
153+
chat_box.fill("Tell me about the documents")
154+
155+
chat_button = chat_box_row.get_by_role("button")
156+
expect(chat_button).to_be_visible()
157+
chat_button.click()

0 commit comments

Comments
 (0)