chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def _load_cli_kit():
|
||||
module_path = Path(__file__).resolve().parents[2] / "scripts" / "_cli_kit.py"
|
||||
spec = importlib.util.spec_from_file_location("cli_kit_under_test", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_log_helpers_do_not_raise_on_legacy_windows_code_page() -> None:
|
||||
buffer = io.BytesIO()
|
||||
stdout = io.TextIOWrapper(buffer, encoding="cp936", errors="strict")
|
||||
|
||||
with mock.patch("sys.stdout", stdout):
|
||||
cli_kit = _load_cli_kit()
|
||||
|
||||
assert sys.stdout.errors == "replace"
|
||||
cli_kit.banner("DeepTutor", ["Backend http://localhost:8001"])
|
||||
cli_kit.log_success("DeepTutor started")
|
||||
cli_kit.log_error("DeepTutor failed")
|
||||
stdout.flush()
|
||||
|
||||
assert buffer.getvalue()
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def _load_module():
|
||||
module_path = Path(__file__).resolve().parents[2] / "scripts" / "docker_compose.py"
|
||||
spec = importlib.util.spec_from_file_location("docker_compose_under_test", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
finally:
|
||||
sys.modules.pop(spec.name, None)
|
||||
|
||||
|
||||
def test_render_docker_env_reads_json_only(tmp_path: Path) -> None:
|
||||
module = _load_module()
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
(settings_dir / "system.json").write_text(
|
||||
json.dumps({"backend_port": 9001, "frontend_port": 4000}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(settings_dir / "integrations.json").write_text(
|
||||
json.dumps({"pocketbase_port": 19090}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_path = tmp_path / "docker.env"
|
||||
|
||||
values = module.render_docker_env(settings_dir, output_path)
|
||||
|
||||
assert values == {
|
||||
"DEEPTUTOR_DOCKER_BACKEND_PORT": "9001",
|
||||
"DEEPTUTOR_DOCKER_FRONTEND_PORT": "4000",
|
||||
"DEEPTUTOR_DOCKER_POCKETBASE_PORT": "19090",
|
||||
}
|
||||
saved = output_path.read_text(encoding="utf-8")
|
||||
assert "\nBACKEND_PORT=" not in saved
|
||||
assert "DEEPTUTOR_DOCKER_BACKEND_PORT=9001" in saved
|
||||
|
||||
|
||||
def test_render_docker_env_uses_defaults_for_missing_or_invalid_json(tmp_path: Path) -> None:
|
||||
module = _load_module()
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
(settings_dir / "system.json").write_text(
|
||||
json.dumps({"backend_port": "bad", "frontend_port": 70000}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_path = tmp_path / "docker.env"
|
||||
|
||||
values = module.render_docker_env(settings_dir, output_path)
|
||||
|
||||
assert values["DEEPTUTOR_DOCKER_BACKEND_PORT"] == "8001"
|
||||
assert values["DEEPTUTOR_DOCKER_FRONTEND_PORT"] == "3782"
|
||||
assert values["DEEPTUTOR_DOCKER_POCKETBASE_PORT"] == "8090"
|
||||
|
||||
|
||||
def test_compose_files_do_not_consume_legacy_env_names() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
for name in ("docker-compose.yml", "docker-compose.ghcr.yml"):
|
||||
content = (root / name).read_text(encoding="utf-8")
|
||||
assert "${BACKEND_PORT" not in content
|
||||
assert "${FRONTEND_PORT" not in content
|
||||
assert "\n - BACKEND_PORT" not in content
|
||||
assert "\n - AUTH_ENABLED" not in content
|
||||
assert "DEEPTUTOR_DOCKER_BACKEND_PORT" in content
|
||||
|
||||
|
||||
def test_dockerfile_is_json_driven_without_bundle_sed() -> None:
|
||||
"""The image no longer rewrites the built bundle at startup (the runtime
|
||||
``sed -i`` broke under a read-only rootfs). URL/auth knowledge is JSON-driven:
|
||||
the entrypoint re-exports runtime settings from data/user/settings/*.json
|
||||
(including DEEPTUTOR_API_BASE_URL / DEEPTUTOR_AUTH_ENABLED) and web/proxy.ts
|
||||
forwards /api/* and /ws/* to the backend at request time."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
content = (root / "Dockerfile").read_text(encoding="utf-8")
|
||||
# The build-time placeholder + runtime bundle sed mechanism is gone.
|
||||
assert "__NEXT_PUBLIC_API_BASE_PLACEHOLDER__" not in content
|
||||
assert "__NEXT_PUBLIC_AUTH_ENABLED_PLACEHOLDER__" not in content
|
||||
# Still JSON-driven: stale runtime env names are ignored and re-exported
|
||||
# from the settings JSON on every start.
|
||||
assert "DEEPTUTOR_IGNORE_PROCESS_ENV_OVERRIDES=1" in content
|
||||
assert 'unset "$key"' in content
|
||||
assert "export_runtime_settings_to_env" in content
|
||||
|
||||
|
||||
def test_supervisord_runs_as_root_with_unprivileged_children() -> None:
|
||||
"""supervisord itself must run as root so it can open the container's
|
||||
stdout/stderr (``/dev/fd/1,2`` — root-owned pipes under a rootful daemon
|
||||
such as Docker Desktop) and write its pidfile under ``/var/run``. Dropping
|
||||
supervisord to the unprivileged ``deeptutor`` user via ``gosu`` made child
|
||||
spawning fail with ``EACCES`` ("making dispatchers ... EACCES"), so neither
|
||||
the backend nor the frontend started under rootful Docker (it only worked
|
||||
under rootless podman). The app processes stay non-root via the per-program
|
||||
``user=deeptutor`` directive instead, which keeps them unprivileged in both
|
||||
runtimes. This guards against reintroducing the ``gosu`` privilege drop.
|
||||
"""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
content = (root / "Dockerfile").read_text(encoding="utf-8")
|
||||
# supervisord is launched directly (as root), not behind a gosu priv-drop.
|
||||
assert "exec /usr/bin/supervisord" in content
|
||||
assert "gosu deeptutor /usr/bin/supervisord" not in content
|
||||
# Every supervisord program drops to the unprivileged deeptutor user, so the
|
||||
# backend/frontend processes never run as root. Each config heredoc closes
|
||||
# with ``EOF``; slice to it so a program's section is bounded correctly.
|
||||
program_blocks = content.split("[program:")[1:]
|
||||
assert program_blocks, "expected supervisord [program:*] sections in the Dockerfile"
|
||||
for block in program_blocks:
|
||||
name = block.splitlines()[0].rstrip("]")
|
||||
section = block.split("EOF")[0]
|
||||
assert "user=deeptutor" in section, (
|
||||
f"supervisord program '{name}' must run as deeptutor (user=deeptutor)"
|
||||
)
|
||||
|
||||
|
||||
def test_frontend_api_is_url_agnostic_passthrough() -> None:
|
||||
"""web/lib/api.ts no longer carries a build-time API base or a placeholder
|
||||
token; apiUrl/wsUrl are pass-throughs and the Next.js middleware
|
||||
(web/proxy.ts) performs the forwarding at request time."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
api_ts = (root / "web" / "lib" / "api.ts").read_text(encoding="utf-8")
|
||||
assert "NEXT_PUBLIC_API_BASE_PLACEHOLDER" not in api_ts
|
||||
assert "process.env.NEXT_PUBLIC_API_BASE" not in api_ts
|
||||
proxy_ts = (root / "web" / "proxy.ts").read_text(encoding="utf-8")
|
||||
assert "DEEPTUTOR_API_BASE_URL" in proxy_ts
|
||||
assert "NextResponse.rewrite" in proxy_ts
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def _load_module():
|
||||
module_path = Path(__file__).resolve().parents[2] / "scripts" / "start_tour.py"
|
||||
spec = importlib.util.spec_from_file_location("start_tour_under_test", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_start_tour_parser_supports_cli_and_home(tmp_path: Path) -> None:
|
||||
start_tour = _load_module()
|
||||
args = start_tour.build_parser().parse_args(["--cli", "--home", str(tmp_path)])
|
||||
|
||||
assert args.cli is True
|
||||
assert args.home == tmp_path
|
||||
|
||||
|
||||
def test_start_tour_delegates_to_init_command(tmp_path: Path) -> None:
|
||||
start_tour = _load_module()
|
||||
with mock.patch.object(start_tour, "run_init") as run_init:
|
||||
start_tour.main(["--cli", "--home", str(tmp_path)])
|
||||
|
||||
run_init.assert_called_once_with(cli_only=True, home=tmp_path)
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def _load_module():
|
||||
module_path = Path(__file__).resolve().parents[2] / "scripts" / "start_web.py"
|
||||
spec = importlib.util.spec_from_file_location("start_web_under_test", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_start_web_parser_supports_home(tmp_path: Path) -> None:
|
||||
start_web = _load_module()
|
||||
args = start_web.build_parser().parse_args(["--home", str(tmp_path)])
|
||||
|
||||
assert args.home == tmp_path
|
||||
|
||||
|
||||
def test_start_web_delegates_to_runtime_launcher(tmp_path: Path) -> None:
|
||||
start_web = _load_module()
|
||||
with mock.patch.object(start_web, "start") as start:
|
||||
start_web.main(["--home", str(tmp_path)])
|
||||
|
||||
start.assert_called_once_with(home=tmp_path)
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _load_update_module():
|
||||
module_path = Path(__file__).resolve().parents[2] / "scripts" / "update.py"
|
||||
module_name = "update_script_under_test"
|
||||
sys.modules.pop(module_name, None)
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
return _run(["git", *args], cwd).stdout.strip()
|
||||
|
||||
|
||||
def _write(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _commit(repo: Path, message: str) -> None:
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-m", message)
|
||||
|
||||
|
||||
def _configure_user(repo: Path) -> None:
|
||||
_git(repo, "config", "user.email", "tests@example.com")
|
||||
_git(repo, "config", "user.name", "DeepTutor Tests")
|
||||
|
||||
|
||||
def _create_checkout(tmp_path: Path, branch: str = "main") -> tuple[Path, Path]:
|
||||
remote = tmp_path / "remote.git"
|
||||
seed = tmp_path / "seed"
|
||||
checkout = tmp_path / "checkout"
|
||||
|
||||
_run(["git", "init", "--bare", str(remote)], tmp_path)
|
||||
_run(["git", "init", "-b", branch, str(seed)], tmp_path)
|
||||
_configure_user(seed)
|
||||
_write(seed / "app.txt", "v1\n")
|
||||
_commit(seed, "initial")
|
||||
_git(seed, "remote", "add", "origin", str(remote))
|
||||
_git(seed, "push", "-u", "origin", branch)
|
||||
|
||||
_run(["git", "clone", "--branch", branch, str(remote), str(checkout)], tmp_path)
|
||||
_configure_user(checkout)
|
||||
return seed, checkout
|
||||
|
||||
|
||||
def _push_remote_commit(seed: Path, branch: str, text: str = "v2\n") -> None:
|
||||
_write(seed / "app.txt", text)
|
||||
_commit(seed, "remote update")
|
||||
_git(seed, "push", "origin", branch)
|
||||
|
||||
|
||||
def test_update_fast_forwards_the_current_non_main_branch(tmp_path: Path) -> None:
|
||||
update = _load_update_module()
|
||||
seed, checkout = _create_checkout(tmp_path, branch="dev")
|
||||
_push_remote_commit(seed, "dev")
|
||||
|
||||
exit_code = update.main(["--repo", str(checkout), "--yes"])
|
||||
|
||||
assert exit_code == 0
|
||||
assert _git(checkout, "branch", "--show-current") == "dev"
|
||||
assert (checkout / "app.txt").read_text(encoding="utf-8") == "v2\n"
|
||||
assert _git(checkout, "rev-parse", "HEAD") == _git(checkout, "rev-parse", "origin/dev")
|
||||
|
||||
|
||||
def test_update_refuses_tracked_local_changes(tmp_path: Path) -> None:
|
||||
update = _load_update_module()
|
||||
seed, checkout = _create_checkout(tmp_path)
|
||||
_push_remote_commit(seed, "main")
|
||||
_write(checkout / "app.txt", "local edit\n")
|
||||
|
||||
exit_code = update.main(["--repo", str(checkout), "--yes"])
|
||||
|
||||
assert exit_code == 1
|
||||
assert (checkout / "app.txt").read_text(encoding="utf-8") == "local edit\n"
|
||||
assert _git(checkout, "rev-parse", "HEAD") != _git(checkout, "rev-parse", "origin/main")
|
||||
|
||||
|
||||
def test_update_refuses_diverged_branches(tmp_path: Path) -> None:
|
||||
update = _load_update_module()
|
||||
seed, checkout = _create_checkout(tmp_path)
|
||||
_push_remote_commit(seed, "main")
|
||||
_write(checkout / "app.txt", "local commit\n")
|
||||
_commit(checkout, "local update")
|
||||
|
||||
exit_code = update.main(["--repo", str(checkout), "--yes"])
|
||||
|
||||
assert exit_code == 1
|
||||
assert (checkout / "app.txt").read_text(encoding="utf-8") == "local commit\n"
|
||||
assert _git(checkout, "rev-parse", "HEAD") != _git(checkout, "rev-parse", "origin/main")
|
||||
|
||||
|
||||
def test_dependency_hints_cover_backend_and_frontend_manifests() -> None:
|
||||
update = _load_update_module()
|
||||
|
||||
hints = update.dependency_hints(
|
||||
["pyproject.toml", "requirements/server.txt", "web/package-lock.json"]
|
||||
)
|
||||
|
||||
assert len(hints) == 2
|
||||
assert "Backend dependencies changed" in hints[0]
|
||||
assert "Frontend dependencies changed" in hints[1]
|
||||
Reference in New Issue
Block a user