claude-dev-env 4.2.0 → 4.3.0

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.
Files changed (78) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  6. package/.agents/skills/pull-request/SKILL.md +218 -0
  7. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  8. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  9. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  10. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  11. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  12. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  13. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  14. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  15. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  16. package/.agents/skills/test-runner/SKILL.md +69 -0
  17. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  18. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  19. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  20. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  21. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  22. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  23. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  24. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  25. package/bin/ever-shipped-skills.mjs +2 -0
  26. package/bin/ever-shipped-skills.test.mjs +8 -0
  27. package/bin/install.mjs +9 -1
  28. package/bin/install.prune.test.mjs +90 -0
  29. package/bin/install.test.mjs +24 -7
  30. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  31. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  33. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  34. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  35. package/hooks/gh_post_body_texts.py +195 -0
  36. package/hooks/hooks.json +0 -15
  37. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  38. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -8
  39. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  40. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  41. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -7
  42. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  43. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  44. package/hooks/observability/test_failure_recorder.py +1 -1
  45. package/hooks/pyproject.toml +1 -1
  46. package/hooks/test_gh_post_body_texts.py +79 -0
  47. package/package.json +1 -1
  48. package/rules/durable-post-artifacts.md +31 -20
  49. package/rules/gh-cli-conventions.md +23 -13
  50. package/rules/git-workflow.md +21 -8
  51. package/scripts/policy_lint/adapter_configuration.py +53 -12
  52. package/scripts/repository_checks/config/constants.py +0 -5
  53. package/scripts/tests/test_adapter_configuration.py +54 -0
  54. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  55. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  56. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  57. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  58. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  59. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  60. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  61. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  62. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  63. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  64. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  65. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  66. package/hooks/blocking/test_preflight_check.py +0 -343
  67. package/hooks/blocking/test_test_preflight_check.py +0 -80
  68. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  69. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  70. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  71. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  72. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  73. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  74. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  75. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  76. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  77. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  78. package/hooks/test__gh_pr_author_swap_utils.py +0 -333
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """Run pytest or Playwright after Django and Playwright readiness checks.
3
+
4
+ ::
5
+
6
+ python run_tests.py -- python -m pytest tests/
7
+ python run_tests.py -- npx playwright test tests/account.spec.ts
8
+
9
+ The selected child receives its arguments unchanged, inherits this process's
10
+ environment and output streams, and runs from the selected project directory.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import NoReturn
23
+
24
+ import preflight_checks
25
+ from test_runner_constants.config.constants import (
26
+ ALL_EXECUTABLE_SUFFIXES,
27
+ ALL_URL_TRAILING_PUNCTUATION,
28
+ ARGUMENT_ASSIGNMENT_SEPARATOR,
29
+ BASE_URL_FLAG,
30
+ COMMAND_SEPARATOR,
31
+ DEFAULT_DJANGO_URL,
32
+ DEFAULT_PLAYWRIGHT_URL,
33
+ ERROR_CHILD_COMMAND_REQUIRED,
34
+ ERROR_CHILD_LAUNCH_TEMPLATE,
35
+ ERROR_COMMAND_SEPARATOR_REQUIRED,
36
+ ERROR_PREFLIGHT_FAILURE_TEMPLATE,
37
+ ERROR_PROJECT_NOT_DIRECTORY_TEMPLATE,
38
+ ERROR_UNSUPPORTED_RUNNER,
39
+ EXIT_CODE_FAILURE,
40
+ EXIT_CODE_INVALID_ARGUMENTS,
41
+ MANAGE_PY_FILENAME,
42
+ MINIMUM_NPX_ARGUMENT_COUNT,
43
+ MINIMUM_PLAYWRIGHT_ARGUMENT_COUNT,
44
+ NPX_EXECUTABLE_NAME,
45
+ PLAYWRIGHT_EXECUTABLE_NAME,
46
+ PLAYWRIGHT_RUNNER_NAME,
47
+ PLAYWRIGHT_TEST_COMMAND_INDEX,
48
+ PLAYWRIGHT_TEST_SUBCOMMAND,
49
+ PROJECT_FLAG,
50
+ PWD_ENVIRONMENT_NAME,
51
+ PYTEST_EXECUTABLE_PREFIX,
52
+ PYTEST_RUNNER_NAME,
53
+ PYTHON_EXECUTABLE_PREFIX,
54
+ PYTHON_MODULE_FLAG,
55
+ SERVER_URL_PATTERN,
56
+ )
57
+
58
+ build_frontend = preflight_checks.build_frontend
59
+ check_django_database = preflight_checks.check_django_database
60
+ check_runserver_port_conflicts = preflight_checks.check_runserver_port_conflicts
61
+ check_server_health = preflight_checks.check_server_health
62
+ check_test_db_flag = preflight_checks.check_test_db_flag
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class SelectedRunner:
67
+ """The supported runner selected from the child arguments."""
68
+
69
+ name: str
70
+ is_playwright: bool
71
+
72
+
73
+ class _ArgumentParser(argparse.ArgumentParser):
74
+ def error(self, message: str) -> NoReturn:
75
+ raise ValueError(message)
76
+
77
+
78
+ def _build_argument_parser() -> argparse.ArgumentParser:
79
+ parser = _ArgumentParser(add_help=True)
80
+ parser.add_argument(PROJECT_FLAG, type=Path, default=None)
81
+ return parser
82
+
83
+
84
+ def _split_command_arguments(
85
+ all_arguments: Sequence[str],
86
+ ) -> tuple[list[str], list[str]]:
87
+ all_arguments_as_list = list(all_arguments)
88
+ if COMMAND_SEPARATOR not in all_arguments_as_list:
89
+ raise ValueError(ERROR_COMMAND_SEPARATOR_REQUIRED)
90
+ separator_index = all_arguments_as_list.index(COMMAND_SEPARATOR)
91
+ launcher_arguments = all_arguments_as_list[:separator_index]
92
+ child_arguments = all_arguments_as_list[separator_index + 1 :]
93
+ if not child_arguments:
94
+ raise ValueError(ERROR_CHILD_COMMAND_REQUIRED)
95
+ return launcher_arguments, child_arguments
96
+
97
+
98
+ def _resolve_project_root(maybe_project_path: Path | None) -> Path:
99
+ selected_path = maybe_project_path or Path(
100
+ os.environ.get(PWD_ENVIRONMENT_NAME, os.getcwd())
101
+ )
102
+ resolved_path = selected_path.expanduser().resolve()
103
+ if not resolved_path.is_dir():
104
+ raise ValueError(ERROR_PROJECT_NOT_DIRECTORY_TEMPLATE.format(resolved_path))
105
+ return resolved_path
106
+
107
+
108
+ def _parse_command_arguments(
109
+ all_arguments: Sequence[str],
110
+ ) -> tuple[Path, list[str]]:
111
+ launcher_arguments, child_arguments = _split_command_arguments(all_arguments)
112
+ parsed_arguments = _build_argument_parser().parse_args(launcher_arguments)
113
+ project_root = _resolve_project_root(parsed_arguments.project)
114
+ return project_root, child_arguments
115
+
116
+
117
+ def _basename_without_suffix(executable_path: str) -> str:
118
+ executable_name = Path(executable_path).name.casefold()
119
+ for each_suffix in ALL_EXECUTABLE_SUFFIXES:
120
+ if executable_name.endswith(each_suffix):
121
+ return executable_name.removesuffix(each_suffix)
122
+ return executable_name
123
+
124
+
125
+ def _is_python_module_runner(
126
+ all_child_arguments: Sequence[str],
127
+ ) -> bool:
128
+ executable_name = _basename_without_suffix(all_child_arguments[0])
129
+ if not executable_name.startswith(PYTHON_EXECUTABLE_PREFIX):
130
+ return False
131
+ for each_index, each_argument in enumerate(all_child_arguments[1:], start=1):
132
+ if each_argument != PYTHON_MODULE_FLAG:
133
+ continue
134
+ return (
135
+ each_index + 1 < len(all_child_arguments)
136
+ and all_child_arguments[each_index + 1] == PYTEST_RUNNER_NAME
137
+ )
138
+ return False
139
+
140
+
141
+ def _is_pytest_executable(executable_path: str) -> bool:
142
+ return _basename_without_suffix(executable_path).startswith(
143
+ PYTEST_EXECUTABLE_PREFIX
144
+ )
145
+
146
+
147
+ def _is_playwright_executable(
148
+ all_child_arguments: Sequence[str],
149
+ ) -> bool:
150
+ executable_name = _basename_without_suffix(all_child_arguments[0])
151
+ if executable_name == PLAYWRIGHT_EXECUTABLE_NAME:
152
+ return (
153
+ len(all_child_arguments) >= MINIMUM_PLAYWRIGHT_ARGUMENT_COUNT
154
+ and all_child_arguments[1] == PLAYWRIGHT_TEST_SUBCOMMAND
155
+ )
156
+ return (
157
+ executable_name == NPX_EXECUTABLE_NAME
158
+ and len(all_child_arguments) >= MINIMUM_NPX_ARGUMENT_COUNT
159
+ and all_child_arguments[1] == PLAYWRIGHT_EXECUTABLE_NAME
160
+ and all_child_arguments[PLAYWRIGHT_TEST_COMMAND_INDEX]
161
+ == PLAYWRIGHT_TEST_SUBCOMMAND
162
+ )
163
+
164
+
165
+ def select_runner(all_child_arguments: Sequence[str]) -> SelectedRunner:
166
+ """Select one supported runner from the child arguments.
167
+
168
+ Args:
169
+ all_child_arguments: Child executable and its arguments.
170
+
171
+ Returns:
172
+ The selected runner classification.
173
+
174
+ Raises:
175
+ ValueError: The child arguments are empty or unsupported.
176
+ """
177
+ if not all_child_arguments:
178
+ raise ValueError(ERROR_CHILD_COMMAND_REQUIRED)
179
+ if _is_playwright_executable(all_child_arguments):
180
+ return SelectedRunner(PLAYWRIGHT_RUNNER_NAME, True)
181
+ if _is_pytest_executable(all_child_arguments[0]) or _is_python_module_runner(
182
+ all_child_arguments
183
+ ):
184
+ return SelectedRunner(PYTEST_RUNNER_NAME, False)
185
+ raise ValueError(ERROR_UNSUPPORTED_RUNNER)
186
+
187
+
188
+ def extract_target_url(
189
+ all_child_arguments: Sequence[str],
190
+ is_playwright: bool,
191
+ ) -> str:
192
+ """Return a URL from the child arguments, or the runner default.
193
+
194
+ Args:
195
+ all_child_arguments: Child executable and its arguments.
196
+ is_playwright: Whether to use the Playwright default URL.
197
+
198
+ Returns:
199
+ A URL from the child arguments, or the runner default.
200
+ """
201
+ for each_index, each_argument in enumerate(all_child_arguments):
202
+ if each_argument == BASE_URL_FLAG and each_index + 1 < len(all_child_arguments):
203
+ return all_child_arguments[each_index + 1].rstrip(
204
+ ALL_URL_TRAILING_PUNCTUATION
205
+ )
206
+ if each_argument.startswith(f"{BASE_URL_FLAG}{ARGUMENT_ASSIGNMENT_SEPARATOR}"):
207
+ return each_argument.split(ARGUMENT_ASSIGNMENT_SEPARATOR, 1)[1].rstrip(
208
+ ALL_URL_TRAILING_PUNCTUATION
209
+ )
210
+ url_match = SERVER_URL_PATTERN.search(each_argument)
211
+ if url_match:
212
+ return url_match.group(0).rstrip(ALL_URL_TRAILING_PUNCTUATION)
213
+ return DEFAULT_PLAYWRIGHT_URL if is_playwright else DEFAULT_DJANGO_URL
214
+
215
+
216
+ def _run_django_preflight(project_root: Path, target_url: str) -> str | None:
217
+ database_error = check_django_database(project_root)
218
+ if database_error:
219
+ return database_error
220
+ return check_server_health(target_url)
221
+
222
+
223
+ def _run_playwright_preflight(
224
+ project_root: Path,
225
+ target_url: str,
226
+ ) -> str | None:
227
+ conflict_error = check_runserver_port_conflicts(target_url, project_root)
228
+ if conflict_error:
229
+ return conflict_error
230
+ test_database_error = check_test_db_flag(target_url, project_root)
231
+ if test_database_error:
232
+ return test_database_error
233
+ return build_frontend(project_root) or check_server_health(target_url)
234
+
235
+
236
+ def run_preflight(
237
+ selected_runner: SelectedRunner,
238
+ project_root: Path,
239
+ all_child_arguments: Sequence[str],
240
+ ) -> str | None:
241
+ """Run checks required by the selected runner before child launch.
242
+
243
+ Args:
244
+ selected_runner: Runner selected from child arguments.
245
+ project_root: Directory used for project checks.
246
+ all_child_arguments: Complete child arguments used for URL selection.
247
+
248
+ Returns:
249
+ A blocking diagnostic when a readiness check fails, otherwise None.
250
+ """
251
+ is_django_project = (project_root / MANAGE_PY_FILENAME).is_file()
252
+ if not selected_runner.is_playwright and not is_django_project:
253
+ return None
254
+ target_url = extract_target_url(all_child_arguments, selected_runner.is_playwright)
255
+ if is_django_project and not selected_runner.is_playwright:
256
+ return _run_django_preflight(project_root, target_url)
257
+ if selected_runner.is_playwright:
258
+ return _run_playwright_preflight(project_root, target_url)
259
+ return check_server_health(target_url)
260
+
261
+
262
+ def run_child_process(
263
+ all_child_arguments: Sequence[str],
264
+ project_root: Path,
265
+ ) -> int:
266
+ """Run the child with inherited streams and environment.
267
+
268
+ Args:
269
+ all_child_arguments: Child executable and its arguments.
270
+ project_root: Working directory for the child.
271
+
272
+ Returns:
273
+ The child's exit status, or one when the child cannot start.
274
+ """
275
+ try:
276
+ completed_process = subprocess.run(
277
+ list(all_child_arguments),
278
+ cwd=project_root,
279
+ check=False,
280
+ )
281
+ except OSError as error:
282
+ print(ERROR_CHILD_LAUNCH_TEMPLATE.format(error), file=sys.stderr)
283
+ return EXIT_CODE_FAILURE
284
+ return completed_process.returncode
285
+
286
+
287
+ def main(all_arguments: Sequence[str]) -> int:
288
+ """Validate arguments, run preflight, then launch the child.
289
+
290
+ Args:
291
+ all_arguments: Command arguments without the executable name.
292
+
293
+ Returns:
294
+ A validation or child exit status.
295
+ """
296
+ try:
297
+ project_root, all_child_arguments = _parse_command_arguments(all_arguments)
298
+ selected_runner = select_runner(all_child_arguments)
299
+ except ValueError as error:
300
+ print(str(error), file=sys.stderr)
301
+ return EXIT_CODE_INVALID_ARGUMENTS
302
+ try:
303
+ preflight_error = run_preflight(
304
+ selected_runner, project_root, all_child_arguments
305
+ )
306
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
307
+ print(ERROR_PREFLIGHT_FAILURE_TEMPLATE.format(error), file=sys.stderr)
308
+ return EXIT_CODE_FAILURE
309
+ if preflight_error:
310
+ print(preflight_error, file=sys.stderr)
311
+ return EXIT_CODE_FAILURE
312
+ return run_child_process(all_child_arguments, project_root)
313
+
314
+
315
+ if __name__ == "__main__":
316
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,185 @@
1
+ """Focused tests for explicit test-runner preflight helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
13
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
14
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
15
+
16
+ preflight_checks = importlib.import_module("preflight_checks")
17
+
18
+
19
+ def test_check_server_health_reports_unhealthy_response(
20
+ monkeypatch: pytest.MonkeyPatch,
21
+ ) -> None:
22
+ completed_process = subprocess.CompletedProcess(
23
+ args=["curl"], returncode=0, stdout="503", stderr=""
24
+ )
25
+ monkeypatch.setattr(
26
+ preflight_checks.subprocess,
27
+ "run",
28
+ lambda *_arguments, **_keywords: completed_process,
29
+ )
30
+
31
+ assert preflight_checks.check_server_health("http://localhost:3000") == (
32
+ "BLOCKED: Server at http://localhost:3000 is not healthy (HTTP 503). "
33
+ "Fix the server before running tests."
34
+ )
35
+
36
+
37
+ def test_check_django_database_requires_db_sqlite3(tmp_path: Path) -> None:
38
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
39
+
40
+ assert preflight_checks.check_django_database(tmp_path) == (
41
+ f"BLOCKED: No database file (db.sqlite3) found in {tmp_path}. "
42
+ "Run migrations before running tests."
43
+ )
44
+
45
+
46
+ def test_extract_port_from_url_uses_explicit_or_default_port() -> None:
47
+ assert preflight_checks.extract_port_from_url("http://localhost:9000") == "9000"
48
+ assert preflight_checks.extract_port_from_url("http://localhost") == "8000"
49
+
50
+
51
+ def test_check_test_db_flag_requires_detected_server_flag(
52
+ tmp_path: Path,
53
+ monkeypatch: pytest.MonkeyPatch,
54
+ ) -> None:
55
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
56
+ monkeypatch.setattr(
57
+ preflight_checks,
58
+ "_read_process_listing",
59
+ lambda: "python manage.py runserver",
60
+ )
61
+
62
+ assert preflight_checks.check_test_db_flag("http://localhost:8000", tmp_path) == (
63
+ "BLOCKED: Django server on port 8000 is not running with --test-db. "
64
+ "Restart with: python manage.py runserver --test-db 0.0.0.0:8000"
65
+ )
66
+
67
+
68
+ def test_check_test_db_flag_ignores_test_database_flag_on_another_port(
69
+ tmp_path: Path,
70
+ monkeypatch: pytest.MonkeyPatch,
71
+ ) -> None:
72
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
73
+ monkeypatch.setattr(
74
+ preflight_checks,
75
+ "_read_process_listing",
76
+ lambda: (
77
+ "python manage.py runserver --test-db 9000\n8000 python manage.py runserver"
78
+ ),
79
+ )
80
+
81
+ assert preflight_checks.check_test_db_flag("http://localhost:8000", tmp_path) == (
82
+ "BLOCKED: Django server on port 8000 is not running with --test-db. "
83
+ "Restart with: python manage.py runserver --test-db 0.0.0.0:8000"
84
+ )
85
+
86
+
87
+ def test_check_test_db_flag_skips_value_taking_runserver_options(
88
+ tmp_path: Path,
89
+ monkeypatch: pytest.MonkeyPatch,
90
+ ) -> None:
91
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
92
+ monkeypatch.setattr(
93
+ preflight_checks,
94
+ "_read_process_listing",
95
+ lambda: "python manage.py runserver --settings project.settings 8000",
96
+ )
97
+
98
+ assert preflight_checks.check_test_db_flag("http://localhost:8000", tmp_path) == (
99
+ "BLOCKED: Django server on port 8000 is not running with --test-db. "
100
+ "Restart with: python manage.py runserver --test-db 0.0.0.0:8000"
101
+ )
102
+
103
+
104
+ def test_check_test_db_flag_accepts_value_taking_options_with_test_db(
105
+ tmp_path: Path,
106
+ monkeypatch: pytest.MonkeyPatch,
107
+ ) -> None:
108
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
109
+ monkeypatch.setattr(
110
+ preflight_checks,
111
+ "_read_process_listing",
112
+ lambda: "python manage.py runserver --settings project.settings --test-db 8000",
113
+ )
114
+
115
+ assert (
116
+ preflight_checks.check_test_db_flag("http://localhost:8000", tmp_path) is None
117
+ )
118
+
119
+
120
+ def test_check_runserver_port_conflicts_reports_other_worktree(
121
+ tmp_path: Path,
122
+ monkeypatch: pytest.MonkeyPatch,
123
+ ) -> None:
124
+ project_root = tmp_path / "project"
125
+ other_project_root = tmp_path / "other-project"
126
+ project_root.mkdir()
127
+ other_project_root.mkdir()
128
+ monkeypatch.setattr(
129
+ preflight_checks,
130
+ "_get_runserver_processes_on_port",
131
+ lambda _port: [(1, str(project_root)), (2, str(other_project_root))],
132
+ )
133
+
134
+ assert (
135
+ preflight_checks.check_runserver_port_conflicts(
136
+ "http://localhost:8000", project_root
137
+ )
138
+ == f"BLOCKED: Multiple Django runserver processes are bound to port 8000 across worktrees: {other_project_root.resolve()}. Stop stale servers first."
139
+ )
140
+
141
+
142
+ def test_build_frontend_runs_build_then_collectstatic(
143
+ tmp_path: Path,
144
+ monkeypatch: pytest.MonkeyPatch,
145
+ ) -> None:
146
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
147
+ (tmp_path / "frontend").mkdir()
148
+ all_calls: list[tuple[list[str], Path]] = []
149
+
150
+ def run_process(
151
+ all_arguments: list[str], **keyword_arguments: object
152
+ ) -> subprocess.CompletedProcess[str]:
153
+ all_calls.append((all_arguments, keyword_arguments["cwd"]))
154
+ return subprocess.CompletedProcess(
155
+ args=all_arguments, returncode=0, stdout="", stderr=""
156
+ )
157
+
158
+ monkeypatch.setattr(preflight_checks.subprocess, "run", run_process)
159
+
160
+ assert preflight_checks.build_frontend(tmp_path) is None
161
+ assert all_calls == [
162
+ (["npm", "run", "build"], tmp_path / "frontend"),
163
+ (["python", "manage.py", "collectstatic", "--noinput"], tmp_path),
164
+ ]
165
+
166
+
167
+ def test_build_frontend_skips_collectstatic_without_manage_py(
168
+ tmp_path: Path,
169
+ monkeypatch: pytest.MonkeyPatch,
170
+ ) -> None:
171
+ (tmp_path / "frontend").mkdir()
172
+ all_calls: list[list[str]] = []
173
+
174
+ def run_process(
175
+ all_arguments: list[str], **_keyword_arguments: object
176
+ ) -> subprocess.CompletedProcess[str]:
177
+ all_calls.append(all_arguments)
178
+ return subprocess.CompletedProcess(
179
+ args=all_arguments, returncode=0, stdout="", stderr=""
180
+ )
181
+
182
+ monkeypatch.setattr(preflight_checks.subprocess, "run", run_process)
183
+
184
+ assert preflight_checks.build_frontend(tmp_path) is None
185
+ assert all_calls == [["npm", "run", "build"]]