Skip to content

Commit caa79c3

Browse files
committed
Improve relative path handling for create_app_fixture
Enhanced the logic for computing and rewriting relative paths from test files to app files in ShinyTestGenerator. Updated documentation and prompt instructions to clarify path requirements and added more robust regex and logging for fixture path replacement. This ensures that generated tests always use correct relative paths, never absolute paths, regardless of directory structure.
1 parent 7230fd8 commit caa79c3

File tree

2 files changed

+133
-29
lines changed

2 files changed

+133
-29
lines changed

shiny/pytest/_generate/_data/_prompts/SYSTEM_PROMPT_testing.md

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ For non-Shiny Python code, respond: "This framework is for Shiny for Python only
99

1010
1. **Dynamic App File**: When generating code that uses `create_app_fixture`, follow these rules:
1111
- Use the exact filename provided in the prompt.
12-
- If the test file is under `app_dir/tests`, make the app path relative to the tests directory.
13-
14-
-`app = create_app_fixture(["../app.py"])`
15-
-`app = create_app_fixture(["app.py"])`
16-
17-
- If the provided filename is in a different path, adjust the path accordingly while keeping it relative.
12+
- ALWAYS make paths relative from the test file directory to the app file.
13+
- For tests in `app_dir/tests` and app in `app_dir/app.py`:
14+
-`app = create_app_fixture(["../app.py"])`
15+
-`app = create_app_fixture(["app.py"])`
16+
- For tests in `tests/subdir` and app in `apps/subdir/app.py`:
17+
-`app = create_app_fixture(["../../apps/subdir/app.py"])`
18+
- NEVER use absolute paths.
19+
- Calculate the correct relative path based on the test file location and app file location.
1820

1921
2. **Controller Classes Only**: Always use official controllers, never `page.locator()`
2022
-`controller.InputSlider(page, "my_slider")`
@@ -36,12 +38,13 @@ For non-Shiny Python code, respond: "This framework is for Shiny for Python only
3638
-`selectize.set("")`
3739

3840
7. **Skip icons**: Do not test icon functionality i.e. using tests like `expect_icon("icon_name")`.
41+
-`btn2.expect_icon("fa-solid fa-shield-halved")`
3942

4043
8. **Skip plots**: Do not test any OutputPlot content or functionality i.e. using `OutputPlot` controller.
4144
- ❌ plot1 = controller.OutputPlot(page, "my_plot_module-plot1")
4245
- ❌ plot1.expect_title("Random Scatter Plot")
4346

44-
9. **Keyword-Only Args**: Always pass every argument as a keyword for every controller method.
47+
9. **Keyword-Only Args**: Always pass every argument as a keyword for every controller method.
4548
-`expect_cell(value="0", row=1, col=2)`
4649
-`expect_cell("0", 1, 2)`
4750

@@ -51,20 +54,22 @@ For non-Shiny Python code, respond: "This framework is for Shiny for Python only
5154

5255
### Checkbox Group
5356
```python
54-
# app_checkbox.py
57+
# apps/app_checkbox.py
5558
from shiny.express import input, ui, render
5659
ui.input_checkbox_group("basic", "Choose:", ["A", "B"], selected=["A"])
5760
@render.text
5861
def output(): return f"Selected: {input.basic()}"
5962

60-
# test_app_checkbox.py
63+
# apps/test_app_checkbox.py
64+
6165
from playwright.sync_api import Page
6266
from shiny.playwright import controller
6367
from shiny.pytest import create_app_fixture
68+
from shiny.run import ShinyAppProc
6469

6570
app = create_app_fixture(["app_checkbox.py"])
6671

67-
def test_checkbox(page: Page, app) -> None:
72+
def test_checkbox(page: Page, app: ShinyAppProc) -> None:
6873
page.goto(app.url)
6974
basic = controller.InputCheckboxGroup(page, "basic")
7075
output = controller.OutputText(page, "output")
@@ -87,8 +92,16 @@ def test_checkbox(page: Page, app) -> None:
8792
from shiny.express import input, ui
8893
ui.input_date("date1", "Date:", value="2024-01-01")
8994

90-
# test_app_date.py
91-
def test_date(page: Page, app) -> None:
95+
# tests/test_app_date.py
96+
from playwright.sync_api import Page
97+
from shiny.playwright import controller
98+
from shiny.pytest import create_app_fixture
99+
from shiny.run import ShinyAppProc
100+
101+
app = create_app_fixture(["../app_date.py"])
102+
103+
104+
def test_date(page: Page, app: ShinyAppProc) -> None:
92105
page.goto(app.url)
93106
date1 = controller.InputDate(page, "date1")
94107

@@ -112,7 +125,15 @@ def output(): return f"Selected: {input.select1()}"
112125
def _(): ui.update_selectize("select1", selected="CA")
113126

114127
# test_app_selectize.py
115-
def test_selectize(page: Page, app) -> None:
128+
from playwright.sync_api import Page
129+
from shiny.playwright import controller
130+
from shiny.pytest import create_app_fixture
131+
from shiny.run import ShinyAppProc
132+
133+
app = create_app_fixture(["app_selectize.py"])
134+
135+
136+
def test_selectize(page: Page, app: ShinyAppProc) -> None:
116137
page.goto(app.url)
117138
select1 = controller.InputSelectize(page, "select1")
118139
output = controller.OutputText(page, "output")

shiny/pytest/_generate/_main.py

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,12 @@ def _compute_relative_app_path(
218218
self, app_file_path: Path, test_file_path: Path
219219
) -> str:
220220
"""Compute POSIX-style relative path from the test file directory to the app file."""
221-
rel = os.path.relpath(str(app_file_path), start=str(test_file_path.parent))
221+
# Make sure both paths are absolute
222+
app_file_abs = app_file_path.resolve()
223+
test_file_abs = test_file_path.resolve()
224+
225+
# Compute relative path from test file directory to app file
226+
rel = os.path.relpath(str(app_file_abs), start=str(test_file_abs.parent))
222227
return Path(rel).as_posix()
223228

224229
def _rewrite_fixture_path(self, test_code: str, relative_app_path: str) -> str:
@@ -229,27 +234,70 @@ def _rewrite_fixture_path(self, test_code: str, relative_app_path: str) -> str:
229234
- create_app_fixture("app.py") -> create_app_fixture("../app.py")
230235
Keeps other arguments intact if present.
231236
"""
237+
logging.debug(f"Rewriting fixture path to: {relative_app_path}")
238+
239+
# First check if create_app_fixture exists in the code
240+
if "create_app_fixture" not in test_code:
241+
logging.warning("No create_app_fixture found in generated test code")
242+
return test_code
243+
232244
# Pattern for list form: create_app_fixture(["app.py"]) or with spaces
233245
pattern_list = re.compile(
234-
r"(create_app_fixture\(\s*\[\s*)(['\"])([^'\"]+)(\\2)(\\s*)([,\\]])",
246+
r"(create_app_fixture\(\s*\[\s*)(['\"])([^'\"]+)(\\2)(\\s*)([,\]])",
235247
re.DOTALL,
236248
)
237249

238250
def repl_list(m: re.Match) -> str:
251+
logging.debug(
252+
f"Replacing list form: '{m.group(3)}' with '{relative_app_path}'"
253+
)
239254
return f"{m.group(1)}{m.group(2)}{relative_app_path}{m.group(2)}{m.group(5)}{m.group(6)}"
240255

241-
new_code, _ = pattern_list.subn(repl_list, test_code)
256+
new_code, list_count = pattern_list.subn(repl_list, test_code)
257+
258+
if list_count > 0:
259+
logging.debug(f"Replaced {list_count} list-form fixture path(s)")
242260

243261
# Pattern for direct string form: create_app_fixture("app.py")
244262
pattern_str = re.compile(
245-
r"(create_app_fixture\(\s*)(['\"])([^'\"]+)(\\2)(\\s*)([,\\)])",
263+
r"(create_app_fixture\(\s*)(['\"])([^'\"]+)(\\2)(\\s*)([,\)])",
246264
re.DOTALL,
247265
)
248266

249267
def repl_str(m: re.Match) -> str:
268+
logging.debug(
269+
f"Replacing string form: '{m.group(3)}' with '{relative_app_path}'"
270+
)
250271
return f"{m.group(1)}{m.group(2)}{relative_app_path}{m.group(2)}{m.group(5)}{m.group(6)}"
251272

252-
new_code2, _ = pattern_str.subn(repl_str, new_code)
273+
new_code2, str_count = pattern_str.subn(repl_str, new_code)
274+
275+
if str_count > 0:
276+
logging.debug(f"Replaced {str_count} string-form fixture path(s)")
277+
278+
# If no replacements were made, there might be a pattern we're not catching
279+
if list_count == 0 and str_count == 0:
280+
logging.warning(
281+
f"Found create_app_fixture but couldn't replace path. Code snippet: {test_code[:200]}..."
282+
)
283+
284+
# Fallback regex with more generous pattern matching
285+
fallback_pattern = re.compile(
286+
r"(create_app_fixture\([^\)]*?['\"])([^'\"]+)(['\"][^\)]*?\))",
287+
re.DOTALL,
288+
)
289+
290+
def fallback_repl(m: re.Match) -> str:
291+
logging.debug(
292+
f"Fallback replacement: '{m.group(2)}' with '{relative_app_path}'"
293+
)
294+
return f"{m.group(1)}{relative_app_path}{m.group(3)}"
295+
296+
new_code2, fallback_count = fallback_pattern.subn(fallback_repl, new_code)
297+
298+
if fallback_count > 0:
299+
logging.debug(f"Fallback replaced {fallback_count} fixture path(s)")
300+
253301
return new_code2
254302

255303
def _create_test_prompt(self, app_text: str, app_file_name: str) -> str:
@@ -259,19 +307,22 @@ def _create_test_prompt(self, app_text: str, app_file_name: str) -> str:
259307
"Please only add controllers for components that already have an ID in the shiny app.\n"
260308
"Do not add tests for ones that do not have an existing ids since controllers need IDs to locate elements.\n"
261309
"and server functionality of this app. Include appropriate assertions \\n"
262-
"and test cases to verify the app's behavior.\n"
263-
"IMPORTANT: In the create_app_fixture call, pass a RELATIVE path from the test file's directory to the app file.\n"
264-
"For example, if the test lives under a 'tests/' subfolder next to the app file, use '../"
265-
+ app_file_name
266-
+ "'. Do not use absolute paths.\n"
310+
"and test cases to verify the app's behavior.\n\n"
311+
"CRITICAL: In the create_app_fixture call, you MUST pass a RELATIVE path from the test file's directory to the app file.\n"
312+
"For example:\n"
313+
"- If test is in 'tests/test_app.py' and app is in 'app.py', use: '../app.py'\n"
314+
"- If test is in 'tests/subdir/test_app.py' and app is in 'apps/subdir/app.py', use: '../../apps/subdir/app.py'\n"
315+
"- Always compute the correct relative path from the test file to the app file\n"
316+
"- NEVER use absolute paths or paths that aren't relative from the test location\n\n"
267317
"IMPORTANT: Only output the Python test code in a single code block. Do not include any explanation, justification, or extra text."
268318
)
269319

270320
def _infer_app_file_path(
271321
self, app_code: Optional[str] = None, app_file_path: Optional[str] = None
272322
) -> Path:
273323
if app_file_path:
274-
return Path(app_file_path)
324+
# Return absolute path to avoid any ambiguity
325+
return Path(app_file_path).resolve()
275326

276327
current_dir = Path.cwd()
277328

@@ -280,10 +331,12 @@ def _infer_app_file_path(
280331
found_files.extend(current_dir.glob(pattern))
281332

282333
if found_files:
283-
return found_files[0]
334+
# Return absolute path of found file
335+
return found_files[0].resolve()
284336

285337
if app_code:
286-
return Path("inferred_app.py")
338+
# For inferred app paths, use absolute path in current directory
339+
return Path("inferred_app.py").resolve()
287340

288341
raise FileNotFoundError(
289342
"Could not infer app file path. Please provide app_file_path parameter."
@@ -294,7 +347,8 @@ def _generate_test_file_path(
294347
) -> Path:
295348
output_dir = output_dir or app_file_path.parent
296349
test_file_name = f"test_{app_file_path.stem}.py"
297-
return output_dir / test_file_name
350+
# Return absolute path for test file
351+
return (output_dir / test_file_name).resolve()
298352

299353
def generate_test(
300354
self,
@@ -328,12 +382,41 @@ def generate_test(
328382
)
329383

330384
try:
385+
# Log the paths for debugging
386+
logging.info(f"App file path: {inferred_app_path}")
387+
logging.info(f"Test file path: {test_file_path}")
388+
331389
relative_app_path = self._compute_relative_app_path(
332390
inferred_app_path, test_file_path
333391
)
392+
393+
logging.info(f"Computed relative path: {relative_app_path}")
394+
395+
# Explicitly check for app.py - this is a common problematic case
396+
if relative_app_path == "app.py" and "../" not in relative_app_path:
397+
logging.warning(
398+
f"Detected possibly incorrect relative path: {relative_app_path}"
399+
)
400+
# Force a proper relative path if needed
401+
if test_file_path.parent != inferred_app_path.parent:
402+
logging.info(
403+
"Test and app are in different directories, adjusting relative path"
404+
)
405+
relative_app_path = f"../{relative_app_path}"
406+
logging.info(f"Adjusted relative path: {relative_app_path}")
407+
334408
test_code = self._rewrite_fixture_path(test_code, relative_app_path)
335-
except Exception:
336-
pass
409+
except Exception as e:
410+
logging.error(f"Error computing relative path: {e}")
411+
# Don't silently ignore - use the best path we can
412+
try:
413+
# Fallback: just use the absolute path as string if we can't compute relative
414+
logging.warning("Falling back to using absolute path in test file")
415+
test_code = self._rewrite_fixture_path(
416+
test_code, str(inferred_app_path.resolve())
417+
)
418+
except Exception as e2:
419+
logging.error(f"Error in fallback path handling: {e2}")
337420

338421
test_file_path.parent.mkdir(parents=True, exist_ok=True)
339422
test_file_path.write_text(test_code, encoding="utf-8")

0 commit comments

Comments
 (0)