Skip to content

Commit 964ee33

Browse files
authored
Merge pull request #66 from john0isaac/add-playwright-tests
Add Playwright Tests
2 parents 252d9e2 + 2093fd9 commit 964ee33

File tree

12 files changed

+219
-18
lines changed

12 files changed

+219
-18
lines changed

.github/workflows/app-tests.yaml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,16 @@ jobs:
7373
- name: Run MyPy
7474
run: python3 -m mypy .
7575
- name: Run Pytest
76-
run: python3 -m pytest
76+
run: python3 -m pytest -s -vv --cov --cov-fail-under=85
77+
- name: Run E2E tests with Playwright
78+
id: e2e
79+
if: runner.os != 'Windows'
80+
run: |
81+
playwright install chromium --with-deps
82+
python3 -m pytest tests/e2e.py --tracing=retain-on-failure
83+
- name: Upload test artifacts
84+
if: ${{ failure() && steps.e2e.conclusion == 'failure' }}
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: playwright-traces${{ matrix.python_version }}
88+
path: test-results

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,5 @@ npm-debug.log*
146146
node_modules
147147
static/
148148

149+
# Playwright test trace
150+
test-results/

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ python_version = 3.12
1111
exclude = [".venv/*"]
1212

1313
[tool.pytest.ini_options]
14-
addopts = "-ra --cov"
14+
addopts = "-ra"
1515
testpaths = ["tests"]
16-
pythonpath = ['src']
16+
pythonpath = ['src/backend']
1717
filterwarnings = ["ignore::DeprecationWarning"]
1818

1919
[[tool.mypy.overrides]]

requirements-dev.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
-r src/backend/requirements.txt
22
ruff
33
mypy
4+
types-requests
45
pre-commit
56
pip-tools
67
pip-compile-cross-platform
8+
playwright
79
pytest
8-
pytest-cov
910
pytest-asyncio
11+
pytest-cov
12+
pytest-playwright
1013
pytest-snapshot
11-
mypy
1214
locust

src/backend/fastapi_app/api_models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ChatRequestContext(BaseModel):
3737
class ChatRequest(BaseModel):
3838
messages: list[ChatCompletionMessageParam]
3939
context: ChatRequestContext
40+
sessionState: Any | None = None
4041

4142

4243
class ThoughtStep(BaseModel):
@@ -54,13 +55,13 @@ class RAGContext(BaseModel):
5455
class RetrievalResponse(BaseModel):
5556
message: Message
5657
context: RAGContext
57-
session_state: Any | None = None
58+
sessionState: Any | None = None
5859

5960

6061
class RetrievalResponseDelta(BaseModel):
6162
delta: Message | None = None
6263
context: RAGContext | None = None
63-
session_state: Any | None = None
64+
sessionState: Any | None = None
6465

6566

6667
class ItemPublic(BaseModel):

src/backend/fastapi_app/dependencies.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ class FastAPIAppContext(BaseModel):
2929
openai_chat_model: str
3030
openai_embed_model: str
3131
openai_embed_dimensions: int
32-
openai_chat_deployment: str
33-
openai_embed_deployment: str
32+
openai_chat_deployment: str | None
33+
openai_embed_deployment: str | None
3434

3535

3636
async def common_parameters():
@@ -51,10 +51,10 @@ async def common_parameters():
5151
openai_chat_deployment = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT", "gpt-35-turbo")
5252
openai_chat_model = os.getenv("AZURE_OPENAI_CHAT_MODEL", "gpt-35-turbo")
5353
elif OPENAI_CHAT_HOST == "ollama":
54-
openai_chat_deployment = "phi3:3.8b"
54+
openai_chat_deployment = None
5555
openai_chat_model = os.getenv("OLLAMA_CHAT_MODEL", "phi3:3.8b")
5656
else:
57-
openai_chat_deployment = "gpt-3.5-turbo"
57+
openai_chat_deployment = None
5858
openai_chat_model = os.getenv("OPENAICOM_CHAT_MODEL", "gpt-3.5-turbo")
5959
return FastAPIAppContext(
6060
openai_chat_model=openai_chat_model,

src/frontend/src/pages/chat/Chat.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ const Chat = () => {
108108
prompt_template: promptTemplate.length === 0 ? undefined : promptTemplate,
109109
temperature: temperature
110110
}
111-
}
111+
},
112+
sessionState: answers.length ? answers[answers.length - 1][1].sessionState : null
112113
};
113114
const chatClient: AIChatProtocolClient = new AIChatProtocolClient("/chat");
114115
if (shouldStream) {

tests/e2e.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import socket
2+
import time
3+
from collections.abc import Generator
4+
from contextlib import closing
5+
from multiprocessing import Process
6+
7+
import pytest
8+
import requests
9+
import uvicorn
10+
from playwright.sync_api import Page, Route, expect
11+
12+
import fastapi_app as app
13+
14+
expect.set_options(timeout=10_000)
15+
16+
17+
def wait_for_server_ready(url: str, timeout: float = 10.0, check_interval: float = 0.5) -> bool:
18+
"""Make requests to provided url until it responds without error."""
19+
conn_error = None
20+
for _ in range(int(timeout / check_interval)):
21+
try:
22+
requests.get(url)
23+
except requests.ConnectionError as exc:
24+
time.sleep(check_interval)
25+
conn_error = str(exc)
26+
else:
27+
return True
28+
raise RuntimeError(conn_error)
29+
30+
31+
@pytest.fixture(scope="session")
32+
def free_port() -> int:
33+
"""Returns a free port for the test server to bind."""
34+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
35+
s.bind(("", 0))
36+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
37+
return s.getsockname()[1]
38+
39+
40+
def run_server(port: int):
41+
uvicorn.run(app.create_app(testing=True), port=port)
42+
43+
44+
@pytest.fixture()
45+
def live_server_url(mock_session_env, free_port: int) -> Generator[str, None, None]:
46+
proc = Process(target=run_server, args=(free_port,), daemon=True)
47+
proc.start()
48+
url = f"http://localhost:{free_port}/"
49+
wait_for_server_ready(url, timeout=10.0, check_interval=0.5)
50+
yield url
51+
proc.kill()
52+
53+
54+
def test_home(page: Page, live_server_url: str):
55+
page.goto(live_server_url)
56+
expect(page).to_have_title("RAG on PostgreSQL")
57+
58+
59+
def test_chat(page: Page, live_server_url: str):
60+
# Set up a mock route to the /chat endpoint with streaming results
61+
def handle(route: Route):
62+
# Assert that session_state is specified in the request (None for now)
63+
if route.request.post_data_json:
64+
session_state = route.request.post_data_json["sessionState"]
65+
assert session_state is None
66+
# Read the JSONL from our snapshot results and return as the response
67+
f = open(
68+
"tests/snapshots/test_api_routes/test_advanced_chat_streaming_flow/advanced_chat_streaming_flow_response.jsonlines"
69+
)
70+
jsonl = f.read()
71+
f.close()
72+
route.fulfill(body=jsonl, status=200, headers={"Transfer-encoding": "Chunked"})
73+
74+
page.route("*/**/chat/stream", handle)
75+
76+
# Check initial page state
77+
page.goto(live_server_url)
78+
expect(page).to_have_title("RAG on PostgreSQL")
79+
expect(page.get_by_role("heading", name="Product chat")).to_be_visible()
80+
expect(page.get_by_role("button", name="Clear chat")).to_be_disabled()
81+
expect(page.get_by_role("button", name="Developer settings")).to_be_enabled()
82+
83+
# Ask a question and wait for the message to appear
84+
page.get_by_placeholder("Type a new question (e.g. does my plan cover annual eye exams?)").click()
85+
page.get_by_placeholder("Type a new question (e.g. does my plan cover annual eye exams?)").fill(
86+
"Whats the dental plan?"
87+
)
88+
page.get_by_role("button", name="Ask question button").click()
89+
90+
expect(page.get_by_text("Whats the dental plan?")).to_be_visible()
91+
expect(page.get_by_text("The capital of France is Paris.")).to_be_visible()
92+
expect(page.get_by_role("button", name="Clear chat")).to_be_enabled()
93+
94+
# Show the thought process
95+
page.get_by_label("Show thought process").click()
96+
expect(page.get_by_title("Thought process")).to_be_visible()
97+
expect(page.get_by_text("Prompt to generate search arguments")).to_be_visible()
98+
99+
# Clear the chat
100+
page.get_by_role("button", name="Clear chat").click()
101+
expect(page.get_by_text("Whats the dental plan?")).not_to_be_visible()
102+
expect(page.get_by_text("The capital of France is Paris.")).not_to_be_visible()
103+
expect(page.get_by_role("button", name="Clear chat")).to_be_disabled()
104+
105+
106+
def test_chat_customization(page: Page, live_server_url: str):
107+
# Set up a mock route to the /chat endpoint
108+
def handle(route: Route):
109+
if route.request.post_data_json:
110+
overrides = route.request.post_data_json["context"]["overrides"]
111+
assert overrides["use_advanced_flow"] is False
112+
assert overrides["retrieval_mode"] == "vectors"
113+
assert overrides["top"] == 1
114+
assert overrides["prompt_template"] == "You are a cat and only talk about tuna."
115+
116+
# Read the JSON from our snapshot results and return as the response
117+
f = open("tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json")
118+
json = f.read()
119+
f.close()
120+
route.fulfill(body=json, status=200)
121+
122+
page.route("*/**/chat", handle)
123+
124+
# Check initial page state
125+
page.goto(live_server_url)
126+
expect(page).to_have_title("RAG on PostgreSQL")
127+
128+
# Customize all the settings
129+
page.get_by_role("button", name="Developer settings").click()
130+
page.get_by_text(
131+
"Use advanced flow with query rewriting and filter formulation. Not compatible with Ollama models."
132+
).click()
133+
page.get_by_label("Retrieve this many matching rows:").click()
134+
page.get_by_label("Retrieve this many matching rows:").fill("1")
135+
page.get_by_text("Vectors + Text (Hybrid)").click()
136+
page.get_by_role("option", name="Vectors", exact=True).click()
137+
page.get_by_label("Override prompt template").click()
138+
page.get_by_label("Override prompt template").fill("You are a cat and only talk about tuna.")
139+
140+
page.get_by_text("Stream chat completion responses").click()
141+
page.locator("button").filter(has_text="Close").click()
142+
143+
# Ask a question and wait for the message to appear
144+
page.get_by_placeholder("Type a new question (e.g. does my plan cover annual eye exams?)").click()
145+
page.get_by_placeholder("Type a new question (e.g. does my plan cover annual eye exams?)").fill(
146+
"Whats the dental plan?"
147+
)
148+
page.get_by_role("button", name="Ask question button").click()
149+
150+
expect(page.get_by_text("Whats the dental plan?")).to_be_visible()
151+
expect(page.get_by_text("The capital of France is Paris.")).to_be_visible()
152+
expect(page.get_by_role("button", name="Clear chat")).to_be_enabled()
153+
154+
155+
def test_chat_nonstreaming(page: Page, live_server_url: str):
156+
# Set up a mock route to the /chat_stream endpoint
157+
def handle(route: Route):
158+
# Read the JSON from our snapshot results and return as the response
159+
f = open("tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json")
160+
json = f.read()
161+
f.close()
162+
route.fulfill(body=json, status=200)
163+
164+
page.route("*/**/chat", handle)
165+
166+
# Check initial page state
167+
page.goto(live_server_url)
168+
expect(page).to_have_title("RAG on PostgreSQL")
169+
expect(page.get_by_role("button", name="Developer settings")).to_be_enabled()
170+
page.get_by_role("button", name="Developer settings").click()
171+
page.get_by_text("Stream chat completion responses").click()
172+
page.locator("button").filter(has_text="Close").click()
173+
174+
# Ask a question and wait for the message to appear
175+
page.get_by_placeholder("Type a new question (e.g. does my plan cover annual eye exams?)").click()
176+
page.get_by_placeholder("Type a new question (e.g. does my plan cover annual eye exams?)").fill(
177+
"Whats the dental plan?"
178+
)
179+
page.get_by_label("Ask question button").click()
180+
181+
expect(page.get_by_text("Whats the dental plan?")).to_be_visible()
182+
expect(page.get_by_text("The capital of France is Paris.")).to_be_visible()
183+
expect(page.get_by_role("button", name="Clear chat")).to_be_enabled()

tests/snapshots/test_api_routes/test_advanced_chat_flow/advanced_chat_flow_response.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,5 @@
6464
],
6565
"followup_questions": null
6666
},
67-
"session_state": null
67+
"sessionState": null
6868
}
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Prompt to generate search arguments","description":["{'role': 'system', 'content': 'Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching database rows.\\nYou have access to an Azure PostgreSQL database with an items table that has columns for title, description, brand, price, and type.\\nGenerate a search query based on the conversation and the new question.\\nIf the question is not in English, translate the question to English before generating the search query.\\nIf you cannot generate a search query, return the original user question.\\nDO NOT return anything besides the query.'}","{'role': 'user', 'content': 'What is the capital of France?'}"],"props":{"model":"gpt-35-turbo","deployment":"gpt-35-turbo"}},{"title":"Search using generated search arguments","description":"The capital of France is Paris. [Benefit_Options-2.pdf].","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":["{'role': 'system', 'content': \"Assistant helps customers with questions about products.\\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\\nAnswer ONLY with the product details listed in the products.\\nIf there isn't enough information below, say you don't know.\\nDo not generate answers that don't use the sources below.\\nEach product has an ID in brackets followed by colon and the product details.\\nAlways include the product ID for each product you use in the response.\\nUse square brackets to reference the source, for example [52].\\nDon't combine citations, list each product separately, for example [27][51].\"}","{'role': 'user', 'content': \"What is the capital of France?\\n\\nSources:\\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear\\n\\n\"}"],"props":{"model":"gpt-35-turbo","deployment":"gpt-35-turbo"}}],"followup_questions":null},"session_state":null}
2-
{"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"session_state":null}
1+
{"delta":null,"context":{"data_points":{"1":{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}},"thoughts":[{"title":"Prompt to generate search arguments","description":["{'role': 'system', 'content': 'Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching database rows.\\nYou have access to an Azure PostgreSQL database with an items table that has columns for title, description, brand, price, and type.\\nGenerate a search query based on the conversation and the new question.\\nIf the question is not in English, translate the question to English before generating the search query.\\nIf you cannot generate a search query, return the original user question.\\nDO NOT return anything besides the query.'}","{'role': 'user', 'content': 'What is the capital of France?'}"],"props":{"model":"gpt-35-turbo","deployment":"gpt-35-turbo"}},{"title":"Search using generated search arguments","description":"The capital of France is Paris. [Benefit_Options-2.pdf].","props":{"top":1,"vector_search":true,"text_search":true,"filters":[]}},{"title":"Search results","description":[{"id":1,"type":"Footwear","brand":"Daybird","name":"Wanderer Black Hiking Boots","description":"Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.","price":109.99}],"props":{}},{"title":"Prompt to generate answer","description":["{'role': 'system', 'content': \"Assistant helps customers with questions about products.\\nRespond as if you are a salesperson helping a customer in a store. Do NOT respond with tables.\\nAnswer ONLY with the product details listed in the products.\\nIf there isn't enough information below, say you don't know.\\nDo not generate answers that don't use the sources below.\\nEach product has an ID in brackets followed by colon and the product details.\\nAlways include the product ID for each product you use in the response.\\nUse square brackets to reference the source, for example [52].\\nDon't combine citations, list each product separately, for example [27][51].\"}","{'role': 'user', 'content': \"What is the capital of France?\\n\\nSources:\\n[1]:Name:Wanderer Black Hiking Boots Description:Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long. Price:109.99 Brand:Daybird Type:Footwear\\n\\n\"}"],"props":{"model":"gpt-35-turbo","deployment":"gpt-35-turbo"}}],"followup_questions":null},"sessionState":null}
2+
{"delta":{"content":"The capital of France is Paris. [Benefit_Options-2.pdf].","role":"assistant"},"context":null,"sessionState":null}

tests/snapshots/test_api_routes/test_simple_chat_flow/simple_chat_flow_response.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,5 @@
5252
],
5353
"followup_questions": null
5454
},
55-
"session_state": null
55+
"sessionState": null
5656
}

0 commit comments

Comments
 (0)