Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions Documentation/technical/testing/dincli-testing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ chain ID 1337 on `http://127.0.0.1:8545`.

## Quick start

The harness is self-contained. All prerequisites (contract compilation, the
local chain node, IPFS daemon) are managed automatically by the conftest. The only manual
requirement is Docker — it must be running before Phase 4 client training begins.
The conftest manages contract compilation and local chain/IPFS startup.
Install the Python dependencies and chain/IPFS toolchains first. The existing
manual path also requires a repo-root `.env` and a packaged
`dincli/config/accounts.json` containing the public development accounts.
Docker must be running before Phase 4 client training begins.

```bash
# 1. Ensure Docker daemon is running
Expand Down Expand Up @@ -50,6 +52,47 @@ That's it. The conftest will:
The test suite is structured around a centralized session-scoped `conftest.py`
that isolates dependencies and coordinates services.

### CI smoke groundwork

The first CI preparation change adds opt-in `DIN_TEST_ISOLATED=1` service
ownership and corrects demo bootstrap to use `connect-demo-wallet`. It does
not yet add a Docker runner, a smoke workflow job, or complete seven-contract
deployment assertions. The Python CI job still selects `not integration`.
Full lifecycle wallet switches and later phases are not verified by this change.

In a disposable checkout/environment, isolated mode requires absolute
`DIN_TEST_TMPDIR` and `DIN_TEST_RESULTS_DIR` paths. Scratch must not already
exist; results must be outside scratch. The harness refuses checkout dotenv
files, uses a local-only subprocess environment, creates a fresh offline Kubo
repository, and rejects occupied ports without terminating their processes.
Only newly started process groups are stopped, including when setup fails
before fixture yield. Startup logs remain in the external results directory.
Both compiler paths remain enabled at this foundation stage; the Foundry-only
selection will accompany the later smoke runner.

Isolated bootstrap validates and reuses accounts 0/1 in the checkout's public
`dincli/config/accounts.json`, preserving the file. If absent, it generates
those accounts from the public Anvil mnemonic and removes only that generated
file on teardown. Invalid accounts or symlinks fail setup. `connect-demo-wallet` reads
this file; setting `ETH_PRIVATE_KEY_N` alone does not supply demo accounts.
Existing manual runs must provide their own public account file with enough
entries for the chosen phases. Real-wallet commands deliberately reject demo
wallets and must not be substituted during bootstrap.

The focused tests below exercise startup failures, process ownership,
interruption, account setup and retained evidence without launching Anvil or
IPFS, compiling contracts or contacting a chain:

```bash
python -m pytest tests/test_integration_demo.py \
tests/test_integration_services.py tests/test_integration_harness.py -q
```

Isolated mode is a harness setting, not a container or a guarantee that an
arbitrary checkout is disposable. Use it only inside an environment you own
for testing. The forthcoming Docker runner will establish that boundary and
provide the shared GitHub/local reproduction command.

### Managed services (`managed_services` fixture)

Before any test runs, the fixture does the following:
Expand Down
101 changes: 88 additions & 13 deletions tests/dincli/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
Run (fail-fast — recommended, because every test depends on the previous one):
pytest tests/dincli/ -v -x -m integration --tb=short 2>&1 | tee ~/tempdir/dincli/results/last_run.txt

All test output / logs are written to ~/tempdir/dincli/ (override with
DIN_TEST_TMPDIR; see tests/dincli/constants.py for all env overrides).
DIN_TEST_ISOLATED=1 opts into owned service groups and fresh disposable state.
It requires explicit scratch and external results paths; it does not create a
container. The default manual service behavior remains unchanged.
"""

import json
Expand All @@ -31,6 +32,7 @@
import sys
import time
from pathlib import Path
from contextlib import ExitStack

import pytest
import requests
Expand All @@ -49,8 +51,14 @@
DIN_TEMP,
NPX_BIN,
IPFS_BIN,
ANVIL_BIN,
ISOLATED_MODE,
RESULTS_DIR,
)

from tests.dincli.demo import bootstrap_demo, prepare_demo_accounts
from tests.dincli.services import start_service, rpc_ready, ipfs_ready


# ---------------------------------------------------------------------------
# Service helpers
Expand Down Expand Up @@ -106,6 +114,7 @@ def _compile_contracts(results_dir: Path) -> None:
capture_output=True,
text=True,
timeout=180,
env=_isolated_env(DIN_TEMP) if ISOLATED_MODE else None,
)
log_path.write_text(result.stdout + result.stderr, encoding="utf-8")
if result.returncode != 0:
Expand All @@ -129,6 +138,7 @@ def _build_foundry_contracts(results_dir: Path) -> None:
capture_output=True,
text=True,
timeout=180,
env=_isolated_env(DIN_TEMP) if ISOLATED_MODE else None,
)
log_path.write_text(result.stdout + result.stderr, encoding="utf-8")
if result.returncode != 0:
Expand Down Expand Up @@ -263,14 +273,25 @@ def din_tmp():

config/ and cache/ are wiped at session start; results/ accumulates runs.
"""
if ISOLATED_MODE:
for directory in (DEVNET_ROOT, FOUNDRY_DIR):
if any(path.name != ".env.example" for path in directory.glob(".env*")):
pytest.fail("Isolated mode requires a disposable checkout without dotenv files")
DIN_TEMP.mkdir(parents=True, exist_ok=False)
try:
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
yield DIN_TEMP
finally:
shutil.rmtree(DIN_TEMP)
return
DIN_TEMP.mkdir(parents=True, exist_ok=True)
for subdir in ("config", "cache"):
d = DIN_TEMP / subdir
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True, exist_ok=True)
(DIN_TEMP / "results").mkdir(exist_ok=True)
return DIN_TEMP
yield DIN_TEMP


# ---------------------------------------------------------------------------
Expand All @@ -292,7 +313,7 @@ def managed_services(din_tmp):
depend on it transitively via bootstrap). On teardown, any processes we
started are terminated.
"""
results_dir = din_tmp / "results"
results_dir = RESULTS_DIR
chain_proc = None
ipfs_proc = None

Expand All @@ -302,6 +323,37 @@ def managed_services(din_tmp):
if PLATFORM_DEPLOY_TOOLCHAIN == "foundry":
_build_foundry_contracts(results_dir)

if ISOLATED_MODE:
env = _isolated_env(din_tmp)
with ExitStack() as resources:
if PLATFORM_DEPLOY_TOOLCHAIN == "foundry":
command = [ANVIL_BIN, "--host", "127.0.0.1", "--chain-id", "1337",
"--accounts", "70", "--balance", "10000", "--block-time", "2",
"--code-size-limit", "4294967295",
"--mnemonic", "test test test test test test test test test test test junk"]
cwd = FOUNDRY_DIR
else:
command = [NPX_BIN, "hardhat", "node", "--hostname", "127.0.0.1"]
cwd = HARDHAT_DIR
chain = start_service(command, cwd=cwd, env=env,
log_path=results_dir / "chain_node.log",
ready=lambda: rpc_ready(HARDHAT_RPC), port=8545)
resources.callback(chain.close)
# Never initialize or reuse an existing IPFS repository.
if Path(env["IPFS_PATH"]).exists():
raise RuntimeError("Isolated IPFS repository already exists")
with (results_dir / "ipfs_init.log").open("w") as log:
for args in (["init"], ["config", "--json", "Bootstrap", "[]"],
["config", "--json", "Discovery.MDNS.Enabled", "false"]):
subprocess.run([IPFS_BIN, *args], env=env, check=True,
stdout=log, stderr=subprocess.STDOUT, timeout=30)
ipfs = start_service([IPFS_BIN, "daemon", "--offline"], cwd=din_tmp, env=env,
log_path=results_dir / "ipfs_daemon.log",
ready=ipfs_ready, port=5001)
resources.callback(ipfs.close)
yield
return

# 2. Fresh chain node (kill and restart for clean EVM state)
if PLATFORM_DEPLOY_TOOLCHAIN == "foundry":
chain_proc = _start_fresh_anvil_node(results_dir)
Expand Down Expand Up @@ -335,6 +387,23 @@ def managed_services(din_tmp):
# ---------------------------------------------------------------------------


def _isolated_env(din_tmp):
"""Local-only subprocess settings; do not inherit provider credentials."""
return {
"PATH": os.environ.get("PATH", os.defpath),
"LANG": "C.UTF-8", "HOME": str(din_tmp / "home"),
"XDG_CONFIG_HOME": str(din_tmp / "config"),
"XDG_CACHE_HOME": str(din_tmp / "cache"),
"XDG_DATA_HOME": str(din_tmp / "data"),
"IPFS_PATH": str(din_tmp / "ipfs"),
"PYTHONPATH": str(DEVNET_ROOT), "LOCAL_RPC_URL": HARDHAT_RPC,
"IPFS_PROVIDER": "env", "IPFS_PUBLIC_GATEWAY": "0",
"IPFS_API_URL_ADD": "http://127.0.0.1:5001/api/v0/add",
"IPFS_API_URL_RETRIEVE": "http://127.0.0.1:5001/api/v0",
"NO_COLOR": "1", "TERM": "dumb",
}


@pytest.fixture(scope="session")
def din_env(din_tmp):
"""
Expand All @@ -346,6 +415,8 @@ def din_env(din_tmp):
local dincli/ package without a pip install into the venv.
- LOCAL_RPC_URL points at the Hardhat node.
"""
if ISOLATED_MODE:
return _isolated_env(din_tmp)
env = os.environ.copy()
env["XDG_CONFIG_HOME"] = str(din_tmp / "config")
env["XDG_CACHE_HOME"] = str(din_tmp / "cache")
Expand All @@ -366,6 +437,8 @@ def workdir():
dincli/ package is found via PYTHONPATH from here.
"""

if ISOLATED_MODE:
return DIN_TEMP
shutil.copy(str(DEVNET_ROOT / ".env"), str(DIN_TEMP / ".env"))
return DIN_TEMP

Expand Down Expand Up @@ -478,19 +551,21 @@ def _run(
@pytest.fixture(scope="session", autouse=True)
def bootstrap(managed_services, din_info_backup, run):
"""
Configure demo mode and local network, and register the named role
Configure demo mode and local network, and connect the named demo role
wallets (account 0 = dinrep / DIN-Representative, account 1 = modelowner).
Tests switch between them with `system connect-wallet <name>`; dynamic
per-account roles in test_04 self-register via
`register-wallet --account N --name acctN --yes --connect`.
Use `system connect-demo-wallet <name>` to switch demo accounts. Later
lifecycle phases still require their own wallet-command migration.
Depends on managed_services so the Hardhat node is guaranteed to be
running before the first command.
"""
run(["system", "init"])
run(["system", "configure-demo"])
run(["system", "configure-network", "--network", "local"])
run(["system", "register-wallet", "--account", "0", "--name", "dinrep", "--yes"])
run(["system", "register-wallet", "--account", "1", "--name", "modelowner", "--yes"])
accounts_path = DEVNET_ROOT / "dincli" / "config" / "accounts.json"
generated_accounts = ISOLATED_MODE and prepare_demo_accounts(accounts_path)
try:
bootstrap_demo(run)
yield
finally:
if generated_accounts:
accounts_path.unlink()


# ---------------------------------------------------------------------------
Expand Down
24 changes: 22 additions & 2 deletions tests/dincli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
IPFS_BIN ipfs binary (default: `ipfs` on PATH, else /usr/local/bin/ipfs)
DIN_TEST_TMPDIR scratch dir for config/cache isolation and logs
(default: ~/tempdir/dincli)
DIN_TEST_ISOLATED set to 1 only in a disposable execution environment;
ignores checkout dotenv and host Python/nvm fallbacks
DIN_TEST_RESULTS_DIR external log directory, required in isolated mode
ANVIL_BIN explicit Anvil binary for owned isolated startup
PLATFORM_DEPLOY_TOOLCHAIN "foundry" or "hardhat" — which platform deploy
script test_deploy_platform_via_script runs (default:
"foundry", matching `dincli system import-deployments`'s
Expand All @@ -29,6 +33,7 @@
HARDHAT_RPC = "http://127.0.0.1:8545"

DEVNET_ROOT = Path(__file__).resolve().parent.parent.parent
ISOLATED_MODE = os.environ.get("DIN_TEST_ISOLATED") == "1"


def _load_dotenv_values(path: Path) -> dict:
Expand All @@ -41,7 +46,7 @@ def _load_dotenv_values(path: Path) -> dict:
return {k: v for k, v in dotenv_values(dotenv_path=path).items() if v is not None}


_DOTENV = _load_dotenv_values(DEVNET_ROOT / ".env")
_DOTENV = {} if ISOLATED_MODE else _load_dotenv_values(DEVNET_ROOT / ".env")


def _env(name: str) -> "str | None":
Expand All @@ -58,6 +63,8 @@ def _resolve_python(env_var: str, default_venv: str) -> str:
override = _env(env_var)
if override:
return override
if ISOLATED_MODE:
return sys.executable
candidate = Path.home() / "my_venvs" / default_venv / "bin" / "python"
return str(candidate) if candidate.exists() else sys.executable

Expand All @@ -66,7 +73,10 @@ def _resolve_npx() -> str:
override = _env("NPX_BIN")
if override:
return override
nvm_candidates = sorted(Path.home().glob(".nvm/versions/node/*/bin/npx"))
nvm_candidates = (
[] if ISOLATED_MODE
else sorted(Path.home().glob(".nvm/versions/node/*/bin/npx"))
)
if nvm_candidates:
return str(nvm_candidates[-1])
return shutil.which("npx") or "npx"
Expand All @@ -92,9 +102,19 @@ def _venv_site_packages(python_bin: str) -> str:

NPX_BIN = _resolve_npx()
FORGE_BIN = _env("FORGE_BIN") or shutil.which("forge") or "forge"
ANVIL_BIN = _env("ANVIL_BIN") or shutil.which("anvil") or "anvil"
IPFS_BIN = _env("IPFS_BIN") or shutil.which("ipfs") or "/usr/local/bin/ipfs"

DIN_TEMP = Path(_env("DIN_TEST_TMPDIR") or str(Path.home() / "tempdir" / "dincli"))
RESULTS_DIR = Path(_env("DIN_TEST_RESULTS_DIR") or str(DIN_TEMP / "results"))
if ISOLATED_MODE:
if not os.environ.get("DIN_TEST_TMPDIR") or not os.environ.get("DIN_TEST_RESULTS_DIR"):
raise ValueError("Isolated mode requires DIN_TEST_TMPDIR and DIN_TEST_RESULTS_DIR")
if not DIN_TEMP.is_absolute() or not RESULTS_DIR.is_absolute():
raise ValueError("Isolated scratch and results paths must be absolute")
if (DIN_TEMP.resolve() == RESULTS_DIR.resolve()
or DIN_TEMP.resolve() in RESULTS_DIR.resolve().parents):
raise ValueError("Isolated results must be outside scratch so cleanup preserves logs")


# Platform deployment (PR 13: transparent proxies; PR 35: foundry parity).
Expand Down
Loading
Loading