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.
- package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
- package/.agents/skills/issue-tracker/SKILL.md +6 -0
- package/.agents/skills/pull-request/SKILL.md +218 -0
- package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
- package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
- package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
- package/.agents/skills/test-runner/SKILL.md +69 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
- package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/install.mjs +9 -1
- package/bin/install.prune.test.mjs +90 -0
- package/bin/install.test.mjs +24 -7
- package/hooks/blocking/pii_prevention_blocker.py +32 -51
- package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
- package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
- package/hooks/gh_post_body_texts.py +195 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -8
- package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
- package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
- package/hooks/observability/test_failure_recorder.py +1 -1
- package/hooks/pyproject.toml +1 -1
- package/hooks/test_gh_post_body_texts.py +79 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +31 -20
- package/rules/gh-cli-conventions.md +23 -13
- package/rules/git-workflow.md +21 -8
- package/scripts/policy_lint/adapter_configuration.py +53 -12
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/tests/test_adapter_configuration.py +54 -0
- package/hooks/_gh_pr_author_swap_utils.py +0 -1225
- package/hooks/blocking/conventional_pr_title_gate.py +0 -443
- package/hooks/blocking/gh_body_arg_blocker.py +0 -176
- package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
- package/hooks/blocking/gh_pr_author_restore.py +0 -105
- package/hooks/blocking/pr_description_writer_gate.py +0 -256
- package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
- package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
- package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
- package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
- package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
- package/hooks/blocking/test_preflight_check.py +0 -343
- package/hooks/blocking/test_test_preflight_check.py +0 -80
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
- package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
- package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
- package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
- package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
- package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
- package/hooks/test__gh_pr_author_swap_utils.py +0 -333
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Contract tests for explicit legacy GitHub author recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import Mock
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
19
|
+
|
|
20
|
+
import recover_legacy_author
|
|
21
|
+
|
|
22
|
+
CURRENT_TIME_SECONDS = 2_000_000.0
|
|
23
|
+
CURRENT_USER_ID = 41
|
|
24
|
+
LEGACY_ACCOUNT = "previous-account"
|
|
25
|
+
OTHER_ACCOUNT = "other-account"
|
|
26
|
+
STATE_FILENAME = "gh_pr_author_swap_session-a.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _completion(
|
|
30
|
+
code: int = 0, stdout: str = "", stderr: str = ""
|
|
31
|
+
) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
return subprocess.CompletedProcess([], code, stdout, stderr)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_state(
|
|
36
|
+
directory: Path,
|
|
37
|
+
*,
|
|
38
|
+
filename: str = STATE_FILENAME,
|
|
39
|
+
payload: object | None = None,
|
|
40
|
+
age_seconds: float = 1801.0,
|
|
41
|
+
) -> Path:
|
|
42
|
+
state_file = directory / filename
|
|
43
|
+
state_payload = {"original_account": LEGACY_ACCOUNT} if payload is None else payload
|
|
44
|
+
state_file.write_text(json.dumps(state_payload), encoding="utf-8")
|
|
45
|
+
os.utime(
|
|
46
|
+
state_file,
|
|
47
|
+
(CURRENT_TIME_SECONDS - age_seconds, CURRENT_TIME_SECONDS - age_seconds),
|
|
48
|
+
)
|
|
49
|
+
if hasattr(os, "getuid"):
|
|
50
|
+
state_file.chmod(0o600)
|
|
51
|
+
return state_file
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run(
|
|
55
|
+
state_file: Path,
|
|
56
|
+
command_runner: Callable[..., subprocess.CompletedProcess[str]],
|
|
57
|
+
*,
|
|
58
|
+
confirm_inactive: bool = True,
|
|
59
|
+
) -> int:
|
|
60
|
+
all_arguments = [str(state_file)]
|
|
61
|
+
if confirm_inactive:
|
|
62
|
+
all_arguments.append("--confirm-inactive")
|
|
63
|
+
return recover_legacy_author.main(
|
|
64
|
+
all_arguments,
|
|
65
|
+
now_seconds=CURRENT_TIME_SECONDS,
|
|
66
|
+
current_user_id=os.getuid() if hasattr(os, "getuid") else None,
|
|
67
|
+
command_runner=command_runner,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_success_restores_selected_record_and_preserves_neighbor(
|
|
72
|
+
tmp_path: Path,
|
|
73
|
+
) -> None:
|
|
74
|
+
selected_file = _write_state(tmp_path)
|
|
75
|
+
neighbor_file = _write_state(
|
|
76
|
+
tmp_path,
|
|
77
|
+
filename="gh_pr_author_swap_session-b.json",
|
|
78
|
+
payload={"original_account": OTHER_ACCOUNT},
|
|
79
|
+
)
|
|
80
|
+
command_runner = Mock(return_value=_completion())
|
|
81
|
+
assert _run(selected_file, command_runner) == 0
|
|
82
|
+
command_runner.assert_called_once_with(
|
|
83
|
+
["gh", "auth", "switch", "--user", LEGACY_ACCOUNT],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
check=False,
|
|
86
|
+
shell=False,
|
|
87
|
+
text=True,
|
|
88
|
+
)
|
|
89
|
+
assert not selected_file.exists()
|
|
90
|
+
assert neighbor_file.exists()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_unconfirmed_record_is_unresolved_without_side_effects(
|
|
94
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
95
|
+
) -> None:
|
|
96
|
+
state_file = _write_state(tmp_path)
|
|
97
|
+
command_runner = Mock()
|
|
98
|
+
assert _run(state_file, command_runner, confirm_inactive=False) == 3
|
|
99
|
+
assert "confirm inactive" in capsys.readouterr().err
|
|
100
|
+
command_runner.assert_not_called()
|
|
101
|
+
assert state_file.exists()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_active_record_is_rejected_without_side_effects(
|
|
105
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
106
|
+
) -> None:
|
|
107
|
+
state_file = _write_state(tmp_path, age_seconds=10.0)
|
|
108
|
+
command_runner = Mock()
|
|
109
|
+
assert _run(state_file, command_runner) == 2
|
|
110
|
+
assert "record is active" in capsys.readouterr().err
|
|
111
|
+
command_runner.assert_not_called()
|
|
112
|
+
assert state_file.exists()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.parametrize(
|
|
116
|
+
"payload",
|
|
117
|
+
[
|
|
118
|
+
[],
|
|
119
|
+
{},
|
|
120
|
+
{"original_account": ""},
|
|
121
|
+
{"original_account": 7},
|
|
122
|
+
{"original_account": LEGACY_ACCOUNT, "extra": True},
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
def test_invalid_schema_is_rejected(
|
|
126
|
+
tmp_path: Path, payload: object, capsys: pytest.CaptureFixture[str]
|
|
127
|
+
) -> None:
|
|
128
|
+
state_file = _write_state(tmp_path, payload=payload)
|
|
129
|
+
command_runner = Mock()
|
|
130
|
+
assert _run(state_file, command_runner) == 2
|
|
131
|
+
assert "record is invalid" in capsys.readouterr().err
|
|
132
|
+
command_runner.assert_not_called()
|
|
133
|
+
assert state_file.exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_invalid_utf8_is_rejected(tmp_path: Path) -> None:
|
|
137
|
+
state_file = _write_state(tmp_path)
|
|
138
|
+
state_file.write_bytes(b"\xff")
|
|
139
|
+
os.utime(state_file, (CURRENT_TIME_SECONDS - 1801, CURRENT_TIME_SECONDS - 1801))
|
|
140
|
+
command_runner = Mock()
|
|
141
|
+
assert _run(state_file, command_runner) == 2
|
|
142
|
+
command_runner.assert_not_called()
|
|
143
|
+
assert state_file.exists()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_wrong_filename_is_rejected(tmp_path: Path) -> None:
|
|
147
|
+
state_file = _write_state(tmp_path, filename="other.json")
|
|
148
|
+
command_runner = Mock()
|
|
149
|
+
assert _run(state_file, command_runner) == 2
|
|
150
|
+
command_runner.assert_not_called()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_symlink_is_rejected(tmp_path: Path) -> None:
|
|
154
|
+
target_file = _write_state(tmp_path)
|
|
155
|
+
link_file = tmp_path / "gh_pr_author_swap_link.json"
|
|
156
|
+
try:
|
|
157
|
+
link_file.symlink_to(target_file)
|
|
158
|
+
except OSError as error:
|
|
159
|
+
pytest.skip(f"symlink unavailable: {error}")
|
|
160
|
+
command_runner = Mock()
|
|
161
|
+
assert _run(link_file, command_runner) == 2
|
|
162
|
+
command_runner.assert_not_called()
|
|
163
|
+
assert target_file.exists()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_failed_restore_preserves_record_and_hides_child_output(
|
|
167
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
168
|
+
) -> None:
|
|
169
|
+
state_file = _write_state(tmp_path)
|
|
170
|
+
command_runner = Mock(
|
|
171
|
+
return_value=_completion(1, LEGACY_ACCOUNT, f"unsafe {LEGACY_ACCOUNT}")
|
|
172
|
+
)
|
|
173
|
+
assert _run(state_file, command_runner) == 1
|
|
174
|
+
captured = capsys.readouterr()
|
|
175
|
+
assert captured.err == "error: GitHub account restore failed\n"
|
|
176
|
+
assert LEGACY_ACCOUNT not in captured.out + captured.err
|
|
177
|
+
assert state_file.exists()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_restore_launch_failure_preserves_record(
|
|
181
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
182
|
+
) -> None:
|
|
183
|
+
state_file = _write_state(tmp_path)
|
|
184
|
+
command_runner = Mock(side_effect=OSError("unavailable"))
|
|
185
|
+
assert _run(state_file, command_runner) == 1
|
|
186
|
+
assert capsys.readouterr().err == "error: GitHub account restore failed\n"
|
|
187
|
+
assert state_file.exists()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_changed_record_after_restore_is_preserved(
|
|
191
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
192
|
+
) -> None:
|
|
193
|
+
state_file = _write_state(tmp_path)
|
|
194
|
+
|
|
195
|
+
def change_record(
|
|
196
|
+
all_arguments: list[str], **keyword_arguments: object
|
|
197
|
+
) -> subprocess.CompletedProcess[str]:
|
|
198
|
+
state_file.write_text(
|
|
199
|
+
json.dumps({"original_account": OTHER_ACCOUNT}), encoding="utf-8"
|
|
200
|
+
)
|
|
201
|
+
return _completion()
|
|
202
|
+
|
|
203
|
+
assert _run(state_file, change_record) == 1
|
|
204
|
+
assert "record changed" in capsys.readouterr().err
|
|
205
|
+
assert state_file.exists()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_secure_posix_metadata_requires_regular_owner_mode() -> None:
|
|
209
|
+
regular_secure_mode = stat.S_IFREG | 0o600
|
|
210
|
+
assert recover_legacy_author._posix_metadata_is_secure(
|
|
211
|
+
regular_secure_mode, CURRENT_USER_ID, CURRENT_USER_ID
|
|
212
|
+
)
|
|
213
|
+
assert not recover_legacy_author._posix_metadata_is_secure(
|
|
214
|
+
regular_secure_mode, CURRENT_USER_ID + 1, CURRENT_USER_ID
|
|
215
|
+
)
|
|
216
|
+
assert not recover_legacy_author._posix_metadata_is_secure(
|
|
217
|
+
stat.S_IFREG | 0o644, CURRENT_USER_ID, CURRENT_USER_ID
|
|
218
|
+
)
|
|
219
|
+
assert not recover_legacy_author._posix_metadata_is_secure(
|
|
220
|
+
stat.S_IFLNK | 0o600, CURRENT_USER_ID, CURRENT_USER_ID
|
|
221
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-runner
|
|
3
|
+
description: >-
|
|
4
|
+
Run pytest or Playwright through the repository's test command.
|
|
5
|
+
Checks Django and Playwright readiness before launching the
|
|
6
|
+
selected child process. Use when running tests in a repository with local
|
|
7
|
+
Django or frontend services.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# test-runner
|
|
11
|
+
|
|
12
|
+
Run Django and Playwright readiness checks, then launch the selected test
|
|
13
|
+
command. Pass one supported runner after `--`. The command forwards those
|
|
14
|
+
arguments without a shell.
|
|
15
|
+
|
|
16
|
+
## Command
|
|
17
|
+
|
|
18
|
+
Run from the repository that owns the tests:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
python "${CLAUDE_SKILL_DIR}/scripts/run_tests.py" -- python -m pytest <pytest-arguments>
|
|
22
|
+
python "${CLAUDE_SKILL_DIR}/scripts/run_tests.py" -- npx playwright test <playwright-arguments>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Use `--project <path>` when the test repository is not the current directory:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
python "${CLAUDE_SKILL_DIR}/scripts/run_tests.py" --project "<repository>" -- python -m pytest <pytest-arguments>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The supported runner forms are `pytest`, `python -m pytest`, `playwright test`,
|
|
32
|
+
and `npx playwright test`. The child receives the exact arguments after `--`,
|
|
33
|
+
inherits the current environment, writes to the current output streams, runs
|
|
34
|
+
with the selected project as its working directory, and returns its exit
|
|
35
|
+
status.
|
|
36
|
+
|
|
37
|
+
## Preflight
|
|
38
|
+
|
|
39
|
+
The command returns nonzero and launches no child when a required check fails.
|
|
40
|
+
|
|
41
|
+
- A pytest run in a project without `manage.py` runs without preflight.
|
|
42
|
+
- A pytest run in a Django project requires `db.sqlite3` and a healthy Django
|
|
43
|
+
server. The URL defaults to `http://localhost:8000`. A URL in the child
|
|
44
|
+
arguments overrides that default.
|
|
45
|
+
- A Playwright run checks its target server, validates a detected Django
|
|
46
|
+
`runserver` process uses `--test-db`, rejects conflicting Django servers from
|
|
47
|
+
different worktrees, and uses the URL default `http://localhost:3000`.
|
|
48
|
+
- When the project contains `frontend/`, the command runs `npm run build` in
|
|
49
|
+
that directory, then `python manage.py collectstatic --noinput` in the
|
|
50
|
+
project directory before Playwright starts.
|
|
51
|
+
|
|
52
|
+
The command does not parse shell syntax. Keep pipes, redirects, `cd`, and
|
|
53
|
+
background operators outside this command. Run this command directly so its
|
|
54
|
+
exit status reaches the caller.
|
|
55
|
+
|
|
56
|
+
## Unsupported commands
|
|
57
|
+
|
|
58
|
+
Use the normal runner for anything else. This command accepts only the four
|
|
59
|
+
runner forms listed above. It does not install dependencies, start a server,
|
|
60
|
+
run migrations, or repair a failed preflight.
|
|
61
|
+
|
|
62
|
+
## Files
|
|
63
|
+
|
|
64
|
+
- `SKILL.md` covers invocation, checks, and the command boundary.
|
|
65
|
+
- `scripts/run_tests.py` parses the command, runs preflight, and launches the child.
|
|
66
|
+
- `scripts/preflight_checks.py` runs Django and Playwright readiness checks.
|
|
67
|
+
- `scripts/test_run_tests.py` covers the command and preflight.
|
|
68
|
+
- `scripts/test_preflight_checks.py` covers the readiness checks.
|
|
69
|
+
- `scripts/test_runner_constants/` holds command names, messages, and limits.
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Django and Playwright readiness checks for check_server_health, check_django_database,
|
|
2
|
+
check_test_db_flag, check_runserver_port_conflicts, and build_frontend."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from test_runner_constants.config.constants import (
|
|
12
|
+
ALL_BLOCKED_STATUS_CODES,
|
|
13
|
+
ALL_COLLECTSTATIC_ARGUMENTS,
|
|
14
|
+
ALL_FRONTEND_BUILD_ARGUMENTS,
|
|
15
|
+
ALL_LOCAL_SERVER_HOSTS,
|
|
16
|
+
ALL_PROCESS_INFORMATION_FIELDS,
|
|
17
|
+
ALL_RUNSERVER_OPTIONS_WITH_VALUES,
|
|
18
|
+
BUILD_TIMEOUT_SECONDS,
|
|
19
|
+
COMMAND_ARGUMENT_SEPARATOR,
|
|
20
|
+
CURL_EXECUTABLE_NAME,
|
|
21
|
+
CURL_MAX_TIME_FLAG,
|
|
22
|
+
CURL_OUTPUT_FLAG,
|
|
23
|
+
CURL_SILENT_FLAG,
|
|
24
|
+
CURL_STATUS_FORMAT,
|
|
25
|
+
CURL_WRITE_OUT_FLAG,
|
|
26
|
+
DEFAULT_DJANGO_PORT,
|
|
27
|
+
DIRECTORY_LIST_SEPARATOR,
|
|
28
|
+
DJANGO_DATABASE_FILENAME,
|
|
29
|
+
EXIT_CODE_SUCCESS,
|
|
30
|
+
FRONTEND_BUILD_FAILED_MESSAGE,
|
|
31
|
+
FRONTEND_DIRECTORY_NAME,
|
|
32
|
+
HEALTH_CHECK_ERROR_TEMPLATE,
|
|
33
|
+
HEALTH_CHECK_TIMEOUT_SECONDS,
|
|
34
|
+
MANAGE_PY_FILENAME,
|
|
35
|
+
MINIMUM_PROCESS_ARGUMENT_COUNT,
|
|
36
|
+
MINIMUM_RUNSERVER_PROCESS_COUNT,
|
|
37
|
+
MISSING_DATABASE_ERROR_TEMPLATE,
|
|
38
|
+
MISSING_TEST_DATABASE_FLAG_TEMPLATE,
|
|
39
|
+
NULL_DEVICE_PATH,
|
|
40
|
+
PORT_CONFLICT_ERROR_TEMPLATE,
|
|
41
|
+
PROCESS_CURL_TIMEOUT_SECONDS,
|
|
42
|
+
PROCESS_LIST_ARGUMENTS,
|
|
43
|
+
PROCESS_LIST_RUNSERVER_TOKEN,
|
|
44
|
+
PROCESS_LIST_SELF_FILTER_TOKEN,
|
|
45
|
+
RUNSERVER_COMMAND_INDEX,
|
|
46
|
+
RUNSERVER_COMMAND_NAME,
|
|
47
|
+
TEST_DATABASE_FLAG,
|
|
48
|
+
UNREACHABLE_ERROR_TEMPLATE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
import psutil
|
|
53
|
+
except ImportError:
|
|
54
|
+
psutil = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_server_health(target_url: str) -> str | None:
|
|
58
|
+
"""Return a diagnostic when the target server is unavailable.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
target_url: HTTP endpoint to request.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A diagnostic string when the endpoint cannot serve tests, otherwise None.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
completed_process = subprocess.run(
|
|
68
|
+
_build_curl_arguments(target_url),
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
timeout=HEALTH_CHECK_TIMEOUT_SECONDS,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
http_status_code = int(completed_process.stdout.strip())
|
|
75
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
76
|
+
return UNREACHABLE_ERROR_TEMPLATE.format(target_url)
|
|
77
|
+
return _server_health_error(target_url, http_status_code)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_curl_arguments(target_url: str) -> list[str]:
|
|
81
|
+
return [
|
|
82
|
+
CURL_EXECUTABLE_NAME,
|
|
83
|
+
CURL_SILENT_FLAG,
|
|
84
|
+
CURL_OUTPUT_FLAG,
|
|
85
|
+
NULL_DEVICE_PATH,
|
|
86
|
+
CURL_WRITE_OUT_FLAG,
|
|
87
|
+
CURL_STATUS_FORMAT,
|
|
88
|
+
CURL_MAX_TIME_FLAG,
|
|
89
|
+
str(PROCESS_CURL_TIMEOUT_SECONDS),
|
|
90
|
+
target_url,
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _server_health_error(target_url: str, http_status_code: int) -> str | None:
|
|
95
|
+
if http_status_code == EXIT_CODE_SUCCESS:
|
|
96
|
+
return UNREACHABLE_ERROR_TEMPLATE.format(target_url)
|
|
97
|
+
if http_status_code in ALL_BLOCKED_STATUS_CODES:
|
|
98
|
+
return HEALTH_CHECK_ERROR_TEMPLATE.format(
|
|
99
|
+
target_url, f"HTTP {http_status_code}"
|
|
100
|
+
)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_django_database(project_root: Path) -> str | None:
|
|
105
|
+
"""Return a diagnostic when a Django database file is missing.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
project_root: Django project directory to inspect.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A diagnostic when manage.py exists without db.sqlite3, otherwise None.
|
|
112
|
+
"""
|
|
113
|
+
manage_py_path = project_root / MANAGE_PY_FILENAME
|
|
114
|
+
if not manage_py_path.is_file():
|
|
115
|
+
return None
|
|
116
|
+
database_path = project_root / DJANGO_DATABASE_FILENAME
|
|
117
|
+
if database_path.is_file():
|
|
118
|
+
return None
|
|
119
|
+
return MISSING_DATABASE_ERROR_TEMPLATE.format(
|
|
120
|
+
DJANGO_DATABASE_FILENAME, project_root
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extract_port_from_url(target_url: str) -> str:
|
|
125
|
+
"""Return the URL port used by Django diagnostics.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
target_url: URL whose authority may carry a port.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The port in the URL, or the Django default when the URL has no valid port.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
parsed_url = urlparse(target_url)
|
|
135
|
+
if parsed_url.port is not None:
|
|
136
|
+
return str(parsed_url.port)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return DEFAULT_DJANGO_PORT
|
|
139
|
+
return DEFAULT_DJANGO_PORT
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_test_db_flag(target_url: str, project_root: Path) -> str | None:
|
|
143
|
+
"""Return a diagnostic when a detected runserver is missing `--test-db`.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
target_url: URL that identifies the expected Django port.
|
|
147
|
+
project_root: Django project that owns the selected test run.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A diagnostic when runserver is found without --test-db, otherwise None.
|
|
151
|
+
"""
|
|
152
|
+
if not (project_root / MANAGE_PY_FILENAME).is_file():
|
|
153
|
+
return None
|
|
154
|
+
if (process_listing := _read_process_listing()) is None:
|
|
155
|
+
return None
|
|
156
|
+
target_port = extract_port_from_url(target_url)
|
|
157
|
+
is_runserver_found = False
|
|
158
|
+
for each_line in process_listing.splitlines():
|
|
159
|
+
if PROCESS_LIST_RUNSERVER_TOKEN not in each_line:
|
|
160
|
+
continue
|
|
161
|
+
if PROCESS_LIST_SELF_FILTER_TOKEN in each_line:
|
|
162
|
+
continue
|
|
163
|
+
runserver_arguments = _extract_runserver_arguments(each_line)
|
|
164
|
+
if not _runserver_uses_target_port(runserver_arguments, target_port):
|
|
165
|
+
continue
|
|
166
|
+
is_runserver_found = True
|
|
167
|
+
if TEST_DATABASE_FLAG in runserver_arguments:
|
|
168
|
+
return None
|
|
169
|
+
if not is_runserver_found:
|
|
170
|
+
return None
|
|
171
|
+
return MISSING_TEST_DATABASE_FLAG_TEMPLATE.format(target_port, target_port)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _extract_runserver_arguments(process_line: str) -> tuple[str, ...]:
|
|
175
|
+
all_process_tokens = tuple(process_line.split())
|
|
176
|
+
try:
|
|
177
|
+
runserver_index = all_process_tokens.index(RUNSERVER_COMMAND_NAME)
|
|
178
|
+
except ValueError:
|
|
179
|
+
return ()
|
|
180
|
+
return all_process_tokens[runserver_index + 1 :]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _runserver_uses_target_port(
|
|
184
|
+
all_runserver_arguments: Sequence[str], target_port: str
|
|
185
|
+
) -> bool:
|
|
186
|
+
endpoint_argument = _find_runserver_endpoint_argument(all_runserver_arguments)
|
|
187
|
+
if endpoint_argument is None:
|
|
188
|
+
return target_port == DEFAULT_DJANGO_PORT
|
|
189
|
+
return endpoint_argument == target_port or endpoint_argument.endswith(
|
|
190
|
+
f":{target_port}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _find_runserver_endpoint_argument(
|
|
195
|
+
all_runserver_arguments: Sequence[str],
|
|
196
|
+
) -> str | None:
|
|
197
|
+
should_skip_next_argument = False
|
|
198
|
+
for each_argument in all_runserver_arguments:
|
|
199
|
+
if should_skip_next_argument:
|
|
200
|
+
should_skip_next_argument = False
|
|
201
|
+
continue
|
|
202
|
+
if each_argument in ALL_RUNSERVER_OPTIONS_WITH_VALUES:
|
|
203
|
+
should_skip_next_argument = True
|
|
204
|
+
continue
|
|
205
|
+
if not each_argument.startswith("-"):
|
|
206
|
+
return each_argument
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read_process_listing() -> str | None:
|
|
211
|
+
try:
|
|
212
|
+
completed_process = subprocess.run(
|
|
213
|
+
list(PROCESS_LIST_ARGUMENTS),
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
timeout=PROCESS_CURL_TIMEOUT_SECONDS,
|
|
217
|
+
check=False,
|
|
218
|
+
)
|
|
219
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
220
|
+
return None
|
|
221
|
+
return completed_process.stdout or ""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_runserver_processes_on_port(
|
|
225
|
+
target_port: str,
|
|
226
|
+
) -> list[tuple[int, str]]:
|
|
227
|
+
if psutil is None:
|
|
228
|
+
return []
|
|
229
|
+
all_runserver_processes: list[tuple[int, str]] = []
|
|
230
|
+
port_token = f":{target_port}"
|
|
231
|
+
for each_process in psutil.process_iter(ALL_PROCESS_INFORMATION_FIELDS):
|
|
232
|
+
process_record = _read_runserver_process(each_process, port_token)
|
|
233
|
+
if process_record is not None:
|
|
234
|
+
all_runserver_processes.append(process_record)
|
|
235
|
+
return all_runserver_processes
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _read_runserver_process(
|
|
239
|
+
process: object,
|
|
240
|
+
port_token: str,
|
|
241
|
+
) -> tuple[int, str] | None:
|
|
242
|
+
try:
|
|
243
|
+
process_information = getattr(process, "info", {})
|
|
244
|
+
if not isinstance(process_information, dict):
|
|
245
|
+
return None
|
|
246
|
+
commandline_parts = process_information.get("cmdline") or []
|
|
247
|
+
if len(commandline_parts) < MINIMUM_PROCESS_ARGUMENT_COUNT:
|
|
248
|
+
return None
|
|
249
|
+
if commandline_parts[1] != MANAGE_PY_FILENAME:
|
|
250
|
+
return None
|
|
251
|
+
if commandline_parts[RUNSERVER_COMMAND_INDEX] != RUNSERVER_COMMAND_NAME:
|
|
252
|
+
return None
|
|
253
|
+
if port_token not in COMMAND_ARGUMENT_SEPARATOR.join(commandline_parts):
|
|
254
|
+
return None
|
|
255
|
+
process_identifier = process_information.get("pid")
|
|
256
|
+
if not isinstance(process_identifier, int):
|
|
257
|
+
return None
|
|
258
|
+
return (process_identifier, process_information.get("cwd") or "")
|
|
259
|
+
except (
|
|
260
|
+
KeyError,
|
|
261
|
+
psutil.AccessDenied,
|
|
262
|
+
psutil.NoSuchProcess,
|
|
263
|
+
psutil.ZombieProcess,
|
|
264
|
+
):
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def check_runserver_port_conflicts(
|
|
269
|
+
target_url: str,
|
|
270
|
+
project_root: Path,
|
|
271
|
+
) -> str | None:
|
|
272
|
+
"""Return a diagnostic for cross-worktree runserver conflicts.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
target_url: URL whose host and port identify the server.
|
|
276
|
+
project_root: Project directory running the test command.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
A diagnostic when separate worktrees share the port, otherwise None.
|
|
280
|
+
"""
|
|
281
|
+
parsed_target_url = urlparse(target_url)
|
|
282
|
+
if (parsed_target_url.hostname or "") not in ALL_LOCAL_SERVER_HOSTS:
|
|
283
|
+
return None
|
|
284
|
+
target_port = str(parsed_target_url.port or DEFAULT_DJANGO_PORT)
|
|
285
|
+
all_processes = _get_runserver_processes_on_port(target_port)
|
|
286
|
+
all_directories = _unique_process_directories(all_processes)
|
|
287
|
+
if _has_no_process_conflict(all_processes, all_directories):
|
|
288
|
+
return None
|
|
289
|
+
return _build_conflict_message(target_port, project_root, all_directories)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _unique_process_directories(
|
|
293
|
+
all_processes: Sequence[tuple[int, str]],
|
|
294
|
+
) -> set[str]:
|
|
295
|
+
return {
|
|
296
|
+
str(Path(each_directory).resolve())
|
|
297
|
+
for _, each_directory in all_processes
|
|
298
|
+
if each_directory
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _has_no_process_conflict(
|
|
303
|
+
all_processes: Sequence[tuple[int, str]],
|
|
304
|
+
all_directories: set[str],
|
|
305
|
+
) -> bool:
|
|
306
|
+
return (
|
|
307
|
+
len(all_processes) < MINIMUM_RUNSERVER_PROCESS_COUNT
|
|
308
|
+
or len(all_directories) < MINIMUM_RUNSERVER_PROCESS_COUNT
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _build_conflict_message(
|
|
313
|
+
target_port: str,
|
|
314
|
+
project_root: Path,
|
|
315
|
+
all_directories: set[str],
|
|
316
|
+
) -> str:
|
|
317
|
+
project_root_realpath = str(project_root.resolve())
|
|
318
|
+
all_other_worktrees = sorted(
|
|
319
|
+
each_directory
|
|
320
|
+
for each_directory in all_directories
|
|
321
|
+
if each_directory != project_root_realpath
|
|
322
|
+
)
|
|
323
|
+
all_conflicting_directories = all_other_worktrees or sorted(all_directories)
|
|
324
|
+
return PORT_CONFLICT_ERROR_TEMPLATE.format(
|
|
325
|
+
target_port, DIRECTORY_LIST_SEPARATOR.join(all_conflicting_directories)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def build_frontend(project_root: Path) -> str | None:
|
|
330
|
+
"""Build the frontend and collect Django static files when present.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
project_root: Project directory containing the optional frontend.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
A diagnostic when either preparation step fails, otherwise None.
|
|
337
|
+
"""
|
|
338
|
+
frontend_path = project_root / FRONTEND_DIRECTORY_NAME
|
|
339
|
+
if not frontend_path.is_dir():
|
|
340
|
+
return None
|
|
341
|
+
if _run_preparation(ALL_FRONTEND_BUILD_ARGUMENTS, frontend_path):
|
|
342
|
+
return FRONTEND_BUILD_FAILED_MESSAGE
|
|
343
|
+
if not (project_root / MANAGE_PY_FILENAME).is_file():
|
|
344
|
+
return None
|
|
345
|
+
if _run_preparation(ALL_COLLECTSTATIC_ARGUMENTS, project_root):
|
|
346
|
+
return FRONTEND_BUILD_FAILED_MESSAGE
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _run_preparation(
|
|
351
|
+
all_arguments: Sequence[str],
|
|
352
|
+
working_directory: Path,
|
|
353
|
+
) -> bool:
|
|
354
|
+
try:
|
|
355
|
+
completed_process = subprocess.run(
|
|
356
|
+
list(all_arguments),
|
|
357
|
+
cwd=working_directory,
|
|
358
|
+
capture_output=True,
|
|
359
|
+
text=True,
|
|
360
|
+
timeout=BUILD_TIMEOUT_SECONDS,
|
|
361
|
+
check=False,
|
|
362
|
+
)
|
|
363
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
364
|
+
return True
|
|
365
|
+
return completed_process.returncode != EXIT_CODE_SUCCESS
|