Skip to content

feat(#66): test environment layer — provisioning, config discovery, test data, config apply - #144

Open
Hareet wants to merge 22 commits into
mainfrom
66-test-environment-layer-implementation
Open

Hareet wants to merge 22 commits into
mainfrom
66-test-environment-layer-implementation

Conversation

@Hareet

@Hareet Hareet commented Jul 18, 2026

Copy link
Copy Markdown
Member

feat(#66): Test Environment Layer — provisioning, config discovery, test data, config apply

Closes #66. Enables #64 (QA Supervisor pipeline invocation).

1. What this PR delivers

The Test Environment Layer for the cht-agent QA Supervisor: a deterministic
(no-LLM) orchestrator that stands up a live CHT instance, applies a cht-conf
config, reads the deployed config back, seeds conforming test data, and resets
between runs — all human-gated for Docker (the agent runs no Docker; a human
brings the environment up/down) and credential-safe (creds ride the cht-conf
--url arg / the HTTP Authorization header, never a log line).

It is the layer that drove the closed-loop demo's QA phase live; this PR ships it
at workbench parity minus the cht-conf-extension pieces (which land in their
own PR — see the deferred map in §7). The pipeline/LangGraph wiring that invokes
this layer arrives with #64; this PR ships the layer + its direct-use surface.

Real paths implemented

  • provision — human-gated bring-up (scripts/test-env-up.sh <cht-core>) +
    a bounded, backing-off readiness poll of /api/v2/monitoring.
  • applyConfig — one cht invocation per upload bucket (app-settings /
    app-forms / contact-forms / resources), status parsed from stdout
    (uploaded / skipped / failed), buckets independent so one failure does
    not abort the rest.
  • discoverConfig — GET /api/v1/settings + the form: _all_docs range,
    parsed into DiscoveredConfig (contact types, roles, permissions, transitions,
    forms, and formVersions — each installed form's rev, the change-detection
    hash for the apply → verify loop).
  • prepareTestData — cht-conf csv-to-docs + upload-docs seed the
    instance, then create-users when users.csv exists; seeded doc ids are
    tracked per environment.
  • reset('couchdb') — the one reset the agent performs itself over the
    CouchDB HTTP API: wipe the tracked docs at their current revs, then reseed
    pristine copies (reseed source pre-flighted before the destructive wipe, so a
    vanished data project fails closed). restart/full stay human-gated.
  • teardown — prints the human docker compose down -v gate and clears the
    per-env tracking.

2. Implementation map

File Role
src/utils/cht-conf-runner.ts child_process isolation for cht-conf: runChtConf (generic, ordered verb list) + runBucket (config-upload buckets), classifyChtConfOutput, minimalEnv (secret-free child env), resolveChtConfBin (CHT_CONF_BIN seam).
src/utils/cht-api.ts fetch isolation for CHT/CouchDB: fetchSettings, fetchFormRevs, fetchDocRevs, bulkDocs (bounded, authed, cred-safe).
src/utils/test-data.ts fs isolation + cht-conf stdout parsers: readSeededDocs, cleanSeededDocs, classifySeededDocs, parseUploadDocsSummary, countCreatedUsers, hasUsersCsv.
src/agents/test-environment-agent.ts The orchestrator (mock + real paths). Docker-free; delegates every side effect to the isolation modules above.
src/agents/test-environment-agent.mock-data.ts Deterministic mock fixtures (CI-safe, no instance).
src/types/index.ts Layer types (Config/Discovery/Provision/Runner/apply sets; app-settings-only; instanceUrl REQUIRED).
src/utils/cht-readiness.ts Readiness poll (Phase 1).
scripts/test-env-up.sh, scripts/test-env-down.sh, docker/cht-agent-net.override.yml Human-gated bring-up/teardown + compose override (Phase 1).

3. Test surface

build + test + lint are green after every commit (Node 22). Full suite:
1112 passing. Real-path specs mock fetch / child_process / fs, so the
suite is green with no instance and no Docker.

Layer spec its
test/utils/cht-conf-runner.spec.ts 26
test/utils/cht-api.spec.ts 13
test/utils/test-data.spec.ts 19
test/agents/test-environment-agent.spec.ts 72
test/utils/cht-readiness.spec.ts 4
Layer total 134

4. Environment seams

  • CHT_CONF_BIN — override the cht-conf binary (default cht); lets the
    agent run a deployment-pinned cht-conf and lets specs stub a fake script.
  • CHT_URL — provision() falls back to it for the instance URL
    (options.url → CHT_URL (trimmed; blank ignored) → the on-network default
    https://nginx). The resolved handle.url is canonicalized (trailing slash
    stripped) and stripped of any embedded basic-auth creds (which survive only as
    an auth fallback, decodeUserinfo-decoded, tolerating a raw %).
  • COUCHDB_USER / COUCHDB_PASSWORD — the instance-auth seam, the same one
    scripts/test-env-up.sh uses for the bring-up, so a non-default password needs
    no code change. Auth precedence: options.auth → URL-embedded creds →
    COUCHDB_* env → the default medic/password.
  • ProvisionOptions.url / ProvisionOptions.auth — the highest-precedence
    target + creds the handle carries; every downstream call reads
    handle.url/handle.auth.
  • useMockDocker (constructor) — mock mode is the default; false selects
    the real paths. This is what keeps CI Docker-free.
  • scripts/test-env-up.sh <cht-core> — the human-gated bring-up seam.

5. Standalone guarantee + independent cht-core demo

Zero imports from workflow / cht-conf-extension code. Real-path specs mock
fetch/child_process → the suite is green with no instance and no Docker.

The demo below exercises the whole layer in real mode
(useMockDocker: false) against a real dockerized CHT, entirely in Docker:
the CHT stack comes up human-gated on cht-agent-net, and the layer runs in
a disposable container on the same network, reaching the instance at the
layer's native https://nginx default through the CHT_URL seam (§4).

1. Bring CHT up (human-gated — the layer itself never runs Docker):

scripts/test-env-up.sh ~/src/cht-core    # local images + stack, joined to cht-agent-net

2. Start a disposable runner on the same network (from this repo's root):

docker run --rm -it --network cht-agent-net \
  -v "$PWD":/app -w /app \
  -v "$HOME/src/cht-core/config/default":/config/default:ro \
  -e CHT_URL=https://nginx \
  -e NODE_TLS_REJECT_UNAUTHORIZED=0 \
  node:22 bash

(NODE_TLS_REJECT_UNAUTHORIZED=0 covers the dev stack's self-signed cert for
the layer's own fetch; the cht-conf child already runs with
--accept-self-signed-certs.)

3. Inside the container — build, then drive every layer method:

npm ci && npm run build && npm install -g cht-conf
mkdir -p /tmp/demo-data/csv && printf 'name\nDemo CHW\n' > /tmp/demo-data/csv/person.csv

node -e "
const { TestEnvironmentAgent } = require('./dist/agents/test-environment-agent');
(async () => {
  const agent = new TestEnvironmentAgent({ useMockDocker: false });
  const handle = await agent.provision({ chtCorePath: '~/src/cht-core' });
  const config = await agent.discoverConfig(handle);
  console.log('forms deployed:', Object.keys(config.formVersions ?? {}).length);
  const apply = await agent.applyConfig(handle, '/config/default');
  console.log('apply:', apply.actions.map(a => a.action + '=' + a.status).join(', '));
  const seeded = await agent.prepareTestData(handle, config, { dataPath: '/tmp/demo-data' });
  console.log('seeded:', JSON.stringify(seeded));
  await agent.reset(handle, 'couchdb');                 // wipe + reseed the tracked docs
  await agent.teardown(handle);                          // prints the human down-gate
})();"

No URL or credentials are passed to any call: provision() resolves the
instance from the CHT_URL env seam and the default auth, strips/canonicalizes
it onto the handle, and every downstream method reads the handle — the §4
seams doing their job. Expected: the readiness poll returns, discovery reports
the deployed forms, all four upload buckets run, the demo person seeds, the
couchdb-tier reset wipes and reseeds it, and teardown prints the human
down-gate.

Mock mode (useMockDocker, the default) mirrors the same call sequence
CI-safe — it is what the spec suite drives. Ticket-driven invocation of this
sequence is #64's scope; this PR ships the layer and the direct-use surface.

6. Excision proof (no cht-conf-extension code)

$ grep -rn "fetchFormXml\|verifyArtifact\|fetchDeployedFormXml\|runOfflineConvert\
\|createConvertSandbox\|skipValidate\|xform-inspect\|config-type\|cht-conf-tier2\
\|qa-workflow\|XlsformBindDiff" src/ test/
$ echo $?
1        # no matches → empty → clean

7. Deferred cht-conf-extension map (verbatim boundary)

Ported with excisions (the stripped items are the cht-conf extension):

Workbench source Stripped in this PR
src/agents/test-environment-agent.ts verifyArtifact, fetchDeployedFormXml, and their imports (fetchFormXml, verifyFormBinds)
src/utils/cht-api.ts fetchFormXml
src/utils/cht-conf-runner.ts offline-convert block (runOfflineConvert, createConvertSandbox, CONVERT_VERBS, SANDBOX_EXCLUDES, OfflineConvertOptions), skipValidate; instanceUrl restored to REQUIRED
src/types/index.ts VerifyArtifact*, QaInput/QaResult/QaTier2Result, XlsformBindDiff, offline-convert optionals
spec files describes for the stripped exports (fetchFormXml, verifyArtifact, runOfflineConvert, createConvertSandbox, F2/F5 xform describes)

Not ported at all (whole files → cht-conf-extension PR):
xform-inspect.ts, config-type.ts, cht-conf-tier2.ts, cht-conf-test-spec.ts,
qa-workflow.ts, orchestrator.ts wiring, CLI flags.

8. Commits (on 66-test-environment-layer-implementation, rebased onto origin/main @ fdf4af2)

  • feat(#66): phase-2 parity uplift — cht-conf runner to workbench parity minus excisions
  • feat(#66): phase 3 — discoverConfig + cht-api, prepareTestData + test-data, couchdb-tier reset
  • test(#66): layer spec suite ported (agent real paths, cht-api, test-data)
  • docs(#66): handoff status, PR description, deferred cht-conf-extension map
  • feat(#66): provision env-seam parity — CHT_URL fallback, cred stripping, COUCHDB_* auth seam
  • chore(#66): review hygiene — dead doc refs, stale roadmap note, should-style agent spec titles

(Phases 1–2 groundwork — provision/readiness/scripts/apply shape — is the branch's
pre-existing history, replayed unchanged by the rebase.)

Hareet and others added 12 commits July 18, 2026 01:17
…elector + result)

Bake the Phase-2 API shape into applyConfig so #134 PR5's cht-conf
diagnose→fix→validate loop plugs in without reshaping the type later.
Real Docker path still throws NOT_IMPLEMENTED (Phase 2); only the type
shape and mock change here.

- ConfigUploadAction buckets (app-settings/app-forms/contact-forms/
  resources) map to their cht-conf verbs; `actions` narrows which run so
  the loop re-uploads only the artifact it changed.
- `artifact` targets a single form (cht-conf --forms=<name>).
- ConfigActionStatus uploaded|skipped|failed — skipped is cht-conf's
  hash-check no-op, which a boolean couldn't express and verify needs.
- applyConfig returns ConfigApplyResult (was void); accepts a bare
  configPath string for back-compat.

573 tests pass, build + lint clean (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t spawn, status parsing)

Implement applyConfig's !useMockDocker path. A new cht-conf-runner isolates
child_process: it spawns `cht` per upload bucket (no shell, explicit argv),
classifies the result as uploaded/skipped/failed, and never logs credentials.

- runBucket() spawns cht-conf with the autonomous-safe flags (--force,
  --skip-*-check, --accept-self-signed-certs); creds ride the --url arg.
- classifyChtConfOutput() parses status per line, skip-detected first — real
  cht-conf "no changes" lines contain the word "uploaded", so naive matching
  misclassifies them; verified against the cht-conf source strings.
- minimalEnv() passes a PATH/HOME allow-list, not the agent's full env, so the
  child (which runs config-project code) can't read LLM provider keys.
- agent loops buckets sequentially; one failure flips succeeded:false without
  aborting the rest. Mock path unchanged; both paths share toApplyResult().

591 tests pass, build + lint clean, coverage 96.66%/86.1% (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y minus excisions

Bring the Phase-2 cht-conf runner up to the workbench lineage's shape (the
port source of truth), excising the cht-conf-extension-only pieces so this
PR stays test-environment-layer scoped.

Parity uplift (from the workbench src/utils/cht-conf-runner.ts):
- Split the spawn wrapper into two layers: runChtConf() runs one `cht`
  process for an ordered verb list and returns the raw ChtConfExecResult;
  runBucket() wraps it for the config-upload buckets and classifies the
  status. The generic layer is what the Phase-3 test-data verbs reuse.
- resolveChtConfBin() adds the CHT_CONF_BIN seam so the agent can run a
  deployment-pinned cht-conf instead of the image's global `cht`.
- Add the `app-settings-only` bucket (upload-app-settings, no compile) for
  a pre-compiled, deployment-recovered app_settings.json.
- Artifact form-filter now rides after a literal `--` separator (cht-conf
  reads only cmdArgs['--'] into args-form-filter; a bare positional throws
  "Unsupported action(s)").
- minimalEnv() env allow-list and per-line skip-first classification
  preserved.

Excisions (cht-conf-extension, deferred to that PR):
- No offline-convert block (runOfflineConvert, createConvertSandbox,
  CONVERT_VERBS, SANDBOX_EXCLUDES, OfflineConvertOptions).
- No skipValidate handling; ChtConfExecOptions.instanceUrl is REQUIRED
  (offline URL-less convert was the only caller that omitted it).

Also fix the stale agent header comment (applyConfig is implemented now).
Types add ChtConfExecOptions/ChtConfExecResult and the app-settings-only
union member. Runner spec ported to parity minus the offline-convert
describes.

1045 tests pass, build + lint clean (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-data, couchdb-tier reset

Land the Phase-3 real paths of the Test Environment Agent, ported from the
workbench lineage (the port source of truth), minus the cht-conf-extension
pieces that belong to that PR.

New HTTP/fs isolation modules (mirroring how cht-readiness.ts isolates fetch):
- src/utils/cht-api.ts — bounded, authed CouchDB/CHT helpers: fetchSettings,
  fetchFormRevs, fetchDocRevs, bulkDocs. Credentials ride the Authorization
  header only, never a URL or log line.
- src/utils/test-data.ts — json_docs reading, seeded-doc classification against
  the discovered config, and cht-conf stdout parsers (upload-docs summary,
  create-users progress; ANSI-stripped).

Agent real paths (mock paths unchanged):
- discoverConfig: GET /api/v1/settings + the form-rev _all_docs range, parsed
  into DiscoveredConfig. formVersions carries each installed form's rev — the
  change-detection hash for the apply -> verify loop.
- prepareTestData: cht-conf csv-to-docs + upload-docs seed the instance, then
  create-users when users.csv exists. Seeded doc ids are tracked per env for
  the reset. Takes a PrepareTestDataOptions (dataPath/bin/timeoutMs).
- reset('couchdb'): the one reset the agent does itself — wipe the tracked docs
  at their CURRENT revs, then reseed pristine copies. The reseed source is
  pre-flighted before the destructive wipe so a vanished data project fails
  closed. restart/full stay human-gated. teardown clears the tracking.

Excisions (cht-conf-extension, deferred): verifyArtifact + fetchDeployedFormXml
and their imports (fetchFormXml from cht-api, verifyFormBinds from xform-inspect
which is not ported); mockVerifyArtifactResult.

Types: DiscoveredConfig.formVersions, TestDataResult.succeeded/seededDocIds,
PrepareTestDataOptions. Mock fixture gains formVersions + seededDocIds. The
three obsolete "throws not-implemented in real mode" agent assertions are
removed here (real-path specs land in the following test commit).

1042 tests pass, build + lint clean (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ata)

Complete the Test Environment Layer's real-path spec coverage, ported from
the workbench lineage minus the excised cht-conf-extension describes. All
real-path specs mock fetch / child_process, so the suite stays green with no
running instance and no Docker.

New unit specs:
- test/utils/cht-api.spec.ts — fetchSettings / fetchFormRevs / fetchDocRevs /
  bulkDocs against a stubbed global fetch (auth header, abort signal, path+status
  error text with no password, non-object/non-array normalisation). The
  fetchFormXml describe is excised with its import.
- test/utils/test-data.spec.ts — the upload-docs/create-users stdout parsers
  (ANSI-stripped), json_docs fixtures on a real tmp dir, and classifySeededDocs
  against a discovered config.

Agent real-mode describes (stubbing cht-api / cht-conf-runner / test-data):
- discoverConfig: fetches with the handle url+auth, parses contact types / roles /
  permissions / transitions dropping junk, lists forms with their revs, propagates
  a fetch failure, and warns on an instance with no contact_types.
- prepareTestData: runs csv-to-docs+upload-docs then create-users, classifies the
  seeded docs, counts users, skips create-users without users.csv, warns on
  partial/empty uploads, and reports succeeded:false on a failed run.
- reset('couchdb'): no-op when nothing tracked; wipes tracked docs at CURRENT revs
  and reseeds; skips tombstones; throws on rejected deletion / failed / partial
  reseed; fails closed before the wipe when the reseed source is gone; refreshes
  tracking; teardown clears tracking. Plus mock formVersions + succeeded/seededDocIds
  parity assertions.

The runner spec landed with the C1 parity uplift; the excised describes
(verifyArtifact, fetchDeployedFormXml, fetchFormXml, offline-convert) are not
ported. Excision grep over src/ and test/ is empty.

1104 tests pass, build + lint clean (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n map

Add PR_66_DESCRIPTION.md (the reviewer-facing writeup) and mark the phase-2
handoff DONE now that phases 2 + 3 have landed.

PR_66_DESCRIPTION.md covers, per the completion plan §7 / §6b:
- what the layer delivers + the implementation map;
- the test surface (1104 passing; 126 in the layer) and the standalone,
  Docker-free / instance-free guarantee + independent cht-core demo recipe;
- the env seams (CHT_CONF_BIN, ProvisionOptions url/auth, useMockDocker,
  human-gated test-env-up.sh) with an explicit scope note on provision;
- the deferred cht-conf-extension boundary map (verbatim);
- the excision-grep proof (empty over src/ + test/);
- per-function parity proofs vs the workbench port source (identical modulo
  the excisions; test-data byte-identical; the one provision divergence called
  out);
- the sonar sweep result (four house rules zero; cognitive-complexity note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng, COUCHDB_* auth seam

Port the workbench provision() env-seam resolver (and the decodeUserinfo helper)
into the real-mode path verbatim. This is test-environment-layer code — nothing
cht-conf about it — and closes a handle-contract gap: the ported phase-2/3
consumers (credentialedUrl in applyConfig/prepareTestData/resetCouchdbTier,
discoverConfig's auth) assume provision's handle invariants — creds stripped off
handle.url, auth resolved into the handle, url canonical (it keys the seeded-doc
tracking map). Leaving provision at the Phase-1 shape risked that drift.

Six behaviors, exactly as the workbench:
- URL fallback order: options.url -> process.env.CHT_URL (trimmed; blank ignored)
  -> the on-network default (https://nginx).
- Resolved URL canonicalized: trailing slashes stripped.
- Embedded basic-auth creds stripped OUT of handle.url (it is logged and passed
  to undici fetch(), which rejects credentialed URLs); they survive only as an
  auth fallback, decoded via decodeUserinfo (tolerates a raw '%' without throwing).
- COUCHDB_USER/COUCHDB_PASSWORD auth seam (the same seam scripts/test-env-up.sh
  uses); COUCHDB_USER absent -> default user; gated on COUCHDB_PASSWORD presence.
- Auth precedence: options.auth -> embedded-URL creds -> COUCHDB_* env -> default.
- Handle shape otherwise unchanged (url, auth copy, network, chtCorePath, source).

Specs: port the workbench describe('CHT_URL fallback') block (8 its) and add the
CHT_URL/COUCHDB_* save/clear/restore to the provision real-mode describe so the
suite stays order-independent. The provision describe is now byte-identical to
the workbench.

Docs: PR_66_DESCRIPTION.md — provision is now at full workbench parity (the only
agent divergence is the excised verifyArtifact/fetchDeployedFormXml); updated the
env-seam list (CHT_URL/COUCHDB_*), the parity table, and the test counts.

1112 tests pass (+8), build + lint clean, excision grep still empty (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d-style agent spec titles

Pre-push code-review cleanup. Comment / spec-title changes only — zero behavior
change (build clean, 1112 tests unchanged, lint clean, excision grep still empty).

- Drop See:-block refs to files that exist in no repo (designs/cht-conf-agent-
  extension.md §7.2) and the transient handoff citation, from the cht-conf-runner
  and cht-api headers and the ConfigUploadAction docblock — keep the durable
  designs/layer_recommendations/test-environment-layer.md reference.
- Agent header: replace the stale "LangGraph node + CLI" roadmap line with
  "The pipeline wiring that invokes this layer lands with #64."
- Compress the provision cred-stripping narration to two lines.
- Spec comment: "workbench env" -> "ambient env".
- docker/cht-agent-net.override.yml: drop the non-ascii warning glyph.
- Agent spec: rename the real-mode it() titles to should-style so the file is
  uniformly should-style (matching the repo's other agent specs / CHT convention);
  the four utils specs stay imperative per the repo's utils convention.
- Agent spec: hoist the shared discoverConfig / reset realAgent into a
  let + beforeEach, matching the couchdb-reset suite's pattern.

The workbench parity source receives these identical edits operator-side, so the
per-function parity claim in PR_66_DESCRIPTION.md §8 stays true (left unreworded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Hareet Hareet changed the title # feat(#66): test environment layer — provisioning, config discovery, test data, config apply feat(#66): test environment layer — provisioning, config discovery, test data, config apply Jul 18, 2026
CHT Agent and others added 2 commits July 18, 2026 04:00
…chers, api polish

Behavior-preserving SonarCloud round for PR #144. No public API changes, no
spec-title changes, no NOSONAR — only helper extraction and assertion polish.

A. S3776 cognitive complexity — every function in the five touched src files now
   lands at <=5 (the repo threshold), via top-level helpers / private methods
   (never inner closures, which count toward the parent's nesting):
   - prepareTestData -> prepareTestDataReal + prepareDocs / seedUsers /
     noteUploadShortfall (the phase steps); warning + log order preserved.
   - classifySeededDocs -> classifyOneDoc / resolveContactType / noteUnknownType.
   - resetCouchdbTier -> wipeTrackedDocs / reseedTrackedDocs / buildTombstones.
   - waitForReady -> probeOnce (single-attempt probe).
   - provision -> resolveRealTarget / resolveRealAuth / buildMockHandle; the six
     documented env-seam behaviors are kept bit-for-bit.
   - parseRoles -> parseRole; runBucket -> deriveBucketStatus; applyConfig ->
     applyConfigReal; reset flattened to a mock-first / tier dispatch.

B. Warnings:
   - FORM_BUCKETS is a Set<ConfigUploadAction>, .has() at both call sites.
   - cht-api request(): 5 params -> 4 (the {method, body} init folded into the
     one options object; request is module-private, callers are in-file).
   - provision trailing-slash strip: the /\/+$/ regex replaced with a
     non-backtracking while (endsWith('/')) slice.
   - Chai dedicated matchers (.to.be.undefined / .to.be.null) at the flagged
     spec sites; test-data.spec asserts the concrete SyntaxError on a malformed
     doc instead of a bare throw.

Verified locally with a one-off eslint-plugin-sonarjs cognitive-complexity
["error", 5] pass over the five src files (all functions pass); the package.json
/ package-lock.json change from that install was reverted.

1112 tests pass (unchanged), build + lint clean, excision grep still empty (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The complexity round introduced a 5-parameter helper (noteUnknownType in
src/utils/test-data.ts), which SonarCloud's S107 (max 4 parameters) flagged.
Bundle the two related sets — the config-known types and the already-warned
types — into one UnknownTypeTracker object, dropping the signature to 4 params.
Behavior-identical: the once-per-type unknown-contact-type warning is unchanged,
and classifySeededDocs stays at cognitive complexity 3.

Re-verified locally with a one-off eslint pass — sonarjs/cognitive-complexity
["error", 5] AND max-params ["error", 4] over the five src files all pass; the
package.json / package-lock.json change from that install was reverted.

1112 tests pass (unchanged), build + lint clean, excision grep still empty (Node 22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sugat009

sugat009 commented Aug 4, 2026

Copy link
Copy Markdown
Member

@Hareet board still says In Progress and I wasn't tagged, so checking before I dig in: is this ready for review, or still moving?

Also PR_66_DESCRIPTION.md and the doc under docs/handoffs/ look like they might not be meant to land, since the PR body already has the description.

Hareet and others added 3 commits August 24, 2026 19:44
…, config paths, gates

- test-env-up.sh passes COUCHDB_USER (medic) so the stack matches DEFAULT_AUTH;
  the local-build default is admin, which 401d every authenticated call
- classifyChtConfOutput: a clean exit with no upload evidence is now skipped +
  warned, never assumed uploaded (unmatched form filter, missing bucket input)
- applyConfig resolves the default config/default against handle.chtCorePath;
  cwd/bin/timeoutMs threaded per bucket
- couchdb-reset reseed reuses the seed's bin/timeoutMs
- human gates: runnable test-env-restart.sh, plus test-env-down.sh fixed (compose
  resolves COUCHDB_* for every subcommand, so both aborted without it)
- gates shell-quote the cht-core path, provision rejects control chars, and the
  printed text is asserted instead of "resolves to undefined"
- spawn failures name bin/cwd so a bad cwd is not misread as a missing cht-conf
- TLS operator note relocated to surviving files (unblocks PR_66_DESCRIPTION.md removal)
- comment dedupe per review (misattached resolveRealTarget JSDoc, duplicated narration)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ted cht-conf output

- provision refuses non-disposable targets: https-only (http for localhost/nginx),
  unknown hosts need allowExternalTarget or CHT_TEST_ENV_ALLOW_EXTERNAL=1, and the
  built-in medic/password is never sent to one; the guard runs before the poll
- reset returns ResetResult {tier, wiped, reseeded, performedBy, protectedSkipped}
  for every tier and accepts {dataPath, docIds} so a reloaded handle can drive it
- reset tracking is keyed on network|url, so parallel envs on nginx no longer collide
- the wipe worklist refuses protected ids (settings, form:*, _design/*, org.couchdb.user:*)
  at tracking and wipe time, so a data project cannot delete deployed config
- bulkDocs throws on a non-array body; the wipe asserts every submitted deletion was
  acknowledged with ok and reports the actual tombstone count
- cht-conf output is redacted at the runner boundary, and a failed bucket now carries
  cht-conf's own explanation in warnings
- an artifact-targeted apply fails only when every form bucket matched nothing, so
  targeting an app form no longer trips over the contact-forms bucket
- authenticated requests use redirect: manual and reject a 3xx

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR_66_DESCRIPTION.md and docs/handoffs/66-phase2-applyconfig-implementation.md were
working artifacts, not deliverables: the PR body carries the description, and main has
no docs/handoffs convention.

The self-signed-TLS note that only existed in PR_66_DESCRIPTION.md was moved into
scripts/test-env-up.sh and the agent's real-path defaults comment beforehand, so no
operator guidance is lost with the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread scripts/test-env-up.sh Fixed
Hareet and others added 4 commits August 24, 2026 23:30
Shell (12): the three test-env scripts are bash, so conditionals use [[ ]]. The npm ci
in test-env-up.sh keeps lifecycle scripts and carries a NOSONAR note — cht-core's
postinstall is patch-package, and --ignore-scripts would leave a broken install.

TypeScript (9): shellQuote uses replaceAll with the POSIX escape hoisted to a constant
(String.raw inline would nest template literals, itself a gate rule); hasControlChars
uses codePointAt; the bulk-docs shape check throws TypeError; and the cwd assertion uses
a dedicated matcher.

Cognitive complexity: extracted isSchemeAllowed/isExternalTargetAllowed from the target
guard, validateProvisionOptions from provision, trackSeededDocs from prepareTestDataReal,
assertNoOrphanDocIds from the reset worklist, and assertNotRedirect from the cht-api
request helper — the redirect check added last round had pushed it over the limit.

Found four more with eslint-plugin-sonarjs run locally than CI reported, since SonarCloud
only annotates changed lines; recipe in the review notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shell (12): the three test-env scripts are bash, so conditionals use [[ ]]. The npm ci
in test-env-up.sh keeps lifecycle scripts on purpose and now says why — cht-core's
postinstall is patch-package, so --ignore-scripts would leave the patches unapplied and
the build broken. That one is a security hotspot to review, not a defect to fix.

TypeScript (9): shellQuote uses replaceAll with the POSIX escape hoisted to a constant
(String.raw inline would nest template literals, itself a gate rule); hasControlChars
uses codePointAt; the bulk-docs shape check throws TypeError; and the cwd assertion uses
a dedicated matcher.

Cognitive complexity: extracted isSchemeAllowed/isExternalTargetAllowed from the target
guard, validateProvisionOptions from provision, trackSeededDocs from prepareTestDataReal,
assertNoOrphanDocIds from the reset worklist, and assertNotRedirect from the cht-api
request helper — the redirect check added last round had pushed it over the limit.

Found four more with eslint-plugin-sonarjs run locally than CI reported, since SonarCloud
only annotates changed lines; recipe in the review notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Hareet

Hareet commented Aug 25, 2026 •

Copy link
Copy Markdown
Member Author

Ready for review now — thanks for waiting, and good catch on those two files.

PR_66_DESCRIPTION.md and the docs/handoffs/ doc were working artifacts; both are removed
from the branch, and the PR body above is now the full description.

Since you last looked, the branch also picked up three rounds of fixes and a live run:

  • Two review passes (correctness / architecture / security, then a pass over the fixes
    themselves). The fixes worth knowing about: default credentials now match the stack our own
    bring-up script creates — previously provision succeeded and then every authenticated call
    401'd; a bucket that uploads nothing is no longer reported as uploaded; provision refuses
    to point cht-conf --force and the doc-deleting reset at anything that is not an obvious
    disposable instance; and the couchdb reset now refuses to wipe ids that name deployed config.
  • The layer was driven end-to-end against a real dockerized CHT — provision → discover →
    apply → seed → reset → teardown, plus failure paths. Results and the exact recipe are in the
    body.
  • The bring-up script now works from a fresh cht-core checkout (it needed npm ci and
    npm run build-dev, which nothing said).

Two things I would keep in your mind: the scope boundary — cht-conf is here as the
tool that makes an instance testable, not as the subject of #134 — and the known-limitations
list
.
CI and SonarCloud are green on the new head.

@witash requesting a review from you so I can give @sugat009 a break. If you do have time to pick up this review, please drop an update so @sugat009 doesn't start one in the mean time.

@Hareet
Hareet requested a review from witash August 25, 2026 05:54
@Hareet Hareet moved this from In Progress to In Review in CHT Multi-Agent System (cht-agent) Aug 25, 2026
@Hareet

Hareet commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

@sugat009 Please review

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed 43a834b. Gates in a clean checkout: tsc clean, 1159 tests pass, nyc thresholds
pass, eslint clean, validate-schema 110/0/3. Merge onto main is clean.

I also ran the layer for real on 2026-09-22, with your scripts, against a fresh cht-core master
and cht-conf 6.6.1. The happy path works end to end, all four bucket classifications hold, and no
credential reached a log line. I can share the run logs on request.

Three items block:

  • Comment 1. prepareTestData deletes files in the operator's data project. Proven live.
  • Comments 12 and 13. The scripts inherit Compose's default project name local-build. On my
    machine that recreated another cht-core checkout's CouchDB container. down -v would have removed
    that stack's volumes. One -p flag fixes all three scripts.
  • Scope. Issue #66 item 3 asks for config-driven generation from contact_types, roles and
    the installed forms. The layer seeds caller CSVs. Please change "Closes #66" to "Part of #66" and
    file the remainder, or tell me which document is wrong.

The other 26 inline comments are non-blocking. Twenty have a one-click fix. The rest carry
code blocks or are questions.

Follow-ups outside the diff:

  • The bring-up publishes nginx on all interfaces at 80 and 443 with medic/password.
  • CI checks none of the scripts. The layer is LLM-free, so a keyless smoke test is possible.
  • The section 5 demo in the PR text cannot run as written. Step 2 mounts the config read-only.
    Every default bucket writes into the --source project: compile writes app_settings.json,
    convert rewrites forms/*/*.xml, uploads write .snapshots/remote.json. So each cht-conf call
    exits 1 with EROFS. Also the gate printer single-quotes chtCorePath: '~/src/cht-core', so
    the shell does not expand ~.
  • No README or runbook section covers the scripts or the env seams. The PR text says 1112 tests
    and 134 its (head: 1159 and 169), says "rebased" over a merge commit, and promises live-run
    results and a known-limitations list that are not in the body.

Comment thread src/agents/test-environment-agent.ts Outdated
config: DiscoveredConfig,
warnings: string[]
): Promise<{ docsOk: boolean; seeded: SeededDoc[]; counts: SeededDocCounts }> => {
const staleDocs = cleanSeededDocs(dataPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): This deletes every *.doc.json under <dataPath>/json_docs before
csv-to-docs runs. json_docs is also the hand-authored input directory for cht-conf
upload-docs. A partner project that keeps source docs there loses them on the host, with one
info log line. The comment on cleanSeededDocs says "left behind by a previous csv-to-docs run",
but nothing checks that this agent wrote them. I proved it on a live stack on 2026-09-22. A
hand-authored json_docs/hand-authored.doc.json was gone after prepareTestData. The doc
never reached CouchDB. Delete only the files this agent tracked for this environment. Refuse when
untracked docs are present:

// prepareTestDataReal: pass what this agent seeded here before
const previous = this.seededData.get(trackingKey(handle));
const { docsOk, seeded, counts } = await prepareDocs(shared, dataPath, config, warnings, previous?.docIds ?? []);

// prepareDocs
const onDisk = readSeededDocs(dataPath).map((doc) => doc.id);
const foreign = onDisk.filter((id) => !ownedIds.includes(id));
if (foreign.length > 0) {
  throw new Error(
    `prepareTestData: ${dataPath}/json_docs holds ${foreign.length} doc file(s) this layer did not write ` +
      `(${foreign.slice(0, 3).join(', ')}). Clear them yourself or use a fresh data project.`
  );
}
const staleDocs = cleanSeededDocs(dataPath);

Comment thread src/agents/test-environment-agent.ts Outdated
Comment on lines +128 to +133
const isDisposableHost = (hostname: string): boolean =>
DISPOSABLE_HOSTS.has(hostname) ||
hostname.endsWith('.local') ||
hostname.endsWith('.localhost') ||
// cht-docker-helper (the published-version bring-up) serves the stack here.
hostname.endsWith('.local-ip.medicmobile.org');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Two suffixes admit hosts that are not disposable.
*.local-ip.medicmobile.org is a wildcard resolver. 203-0-113-5.local-ip.medicmobile.org
resolves to 203.0.113.5, a public address, so any host passes with the built-in credentials.
*.local is also the legacy Active Directory suffix. Restrict the resolver to private ranges and
drop .local:

Suggested change
const isDisposableHost = (hostname: string): boolean =>
DISPOSABLE_HOSTS.has(hostname) ||
hostname.endsWith('.local') ||
hostname.endsWith('.localhost') ||
// cht-docker-helper (the published-version bring-up) serves the stack here.
hostname.endsWith('.local-ip.medicmobile.org');
const PRIVATE_DASHED_IP =
/^(?:10-\d{1,3}-\d{1,3}-\d{1,3}|172-(?:1[6-9]|2\d|3[01])-\d{1,3}-\d{1,3}|192-168-\d{1,3}-\d{1,3}|127-\d{1,3}-\d{1,3}-\d{1,3})\.local-ip\.medicmobile\.org$/;
const isDisposableHost = (hostname: string): boolean =>
DISPOSABLE_HOSTS.has(hostname) ||
hostname.endsWith('.localhost') ||
// cht-docker-helper serves the stack on a dashed private IP. The resolver answers
// for ANY IP, so public addresses must not pass.
PRIVATE_DASHED_IP.test(hostname);

Comment thread src/agents/test-environment-agent.ts Outdated
* instance's app settings.
*/
const PROTECTED_DOC_ID =
/^(settings|resources|branding|partners|extension-libs)$|^_design\/|^form:|^org\.couchdb\.user:|^messages-/;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Three deployed-config docs are missing from this list:
privacy-policies (written by cht-conf upload-privacy-policies), service-worker-meta, and
zscore-charts. All three are literal doc ids in cht-core api/src on master. reset('couchdb')
wipes any of them when a data project names it.

Suggested change
/^(settings|resources|branding|partners|extension-libs)$|^_design\/|^form:|^org\.couchdb\.user:|^messages-/;
/^(settings|resources|branding|partners|extension-libs|privacy-policies|service-worker-meta|zscore-charts)$|^_design\/|^form:|^org\.couchdb\.user:|^messages-/;

Comment thread src/agents/test-environment-agent.ts Outdated
// upload-docs re-uploads the whole json_docs directory, protected ids included;
// only the docs this layer owns count towards a complete reseed (a protected doc
// conflicting with deployed config must not fail every future reset).
const expected = partitionProtected(onDisk.map((doc) => doc.id)).safe.length;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): reset('couchdb') with an explicit docIds subset throws after
the wipe. upload-docs re-uploads all of json_docs. The docs not in the subset still exist
on the instance and count as skipped. So uploadedCount is below expected. Count only the
wiped ids:

Suggested change
const expected = partitionProtected(onDisk.map((doc) => doc.id)).safe.length;
const wipedIds = new Set(tracked.docIds);
const expected = partitionProtected(onDisk.map((doc) => doc.id)).safe.filter((id) => wipedIds.has(id)).length;

Comment on lines +244 to +247
const timeoutId = setTimeout(() => {
proc.kill('SIGTERM');
finish({ exitCode: null, timedOut: true });
}, timeoutMs);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): On timeout this sends one SIGTERM and resolves. A child that
ignores it keeps running after the layer reports failed. cht-conf spawns xls2xform during
form conversion. That grandchild can outlive the timeout too. Escalate:

Suggested change
const timeoutId = setTimeout(() => {
proc.kill('SIGTERM');
finish({ exitCode: null, timedOut: true });
}, timeoutMs);
const timeoutId = setTimeout(() => {
proc.kill('SIGTERM');
const hardKill = setTimeout(() => proc.kill('SIGKILL'), 5_000);
proc.once('close', () => clearTimeout(hardKill));
finish({ exitCode: null, timedOut: true });
}, timeoutMs);

Comment thread src/agents/test-environment-agent.ts Outdated
console.log(` scripts/test-env-up.sh${target} # build + start on ${network}`);
console.log(`[Test Environment Agent] Polling ${url}/api/v2/monitoring until healthy...`);

await waitForReady(url, { maxWaitMs: DEFAULT_PROVISION_WAIT_MS, ...options.readiness });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): /api/v2/monitoring is unauthenticated, so this returns a
"ready" handle with unchecked credentials. In my live run, provision succeeded with a wrong
password, and discoverConfig failed minutes later with HTTP 401. One authenticated probe here
moves the failure to where the operator is looking:

Suggested change
await waitForReady(url, { maxWaitMs: DEFAULT_PROVISION_WAIT_MS, ...options.readiness });
await waitForReady(url, { maxWaitMs: DEFAULT_PROVISION_WAIT_MS, ...options.readiness });
// /api/v2/monitoring is unauthenticated: probe the credentials once, so a wrong
// password fails here and not on the first discover/apply call minutes later.
await fetchSettings(url, auth);

Comment thread src/agents/test-environment-agent.ts Outdated
Comment on lines +648 to +649
* Defaults to cht-core's in-repo `config/default`; cht-conf tickets pass the
* mounted deployment config (CHT_CONF_PATH). `actions` selects which cht-conf

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): Nothing reads CHT_CONF_PATH. It appears only here and in
types/index.ts:808. A reader will set it and see no effect.

Suggested change
* Defaults to cht-core's in-repo `config/default`; cht-conf tickets pass the
* mounted deployment config (CHT_CONF_PATH). `actions` selects which cht-conf
* Defaults to cht-core's in-repo `config/default`. Pass `configPath` for a deployment
* config; no env var selects one yet. `actions` selects which cht-conf

Comment on lines +868 to +869
printResetGate(handle, tier);
return gated('human-gate');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): provision blocks until the human gate is done. reset('restart')
and reset('full') print the gate and return at once with wiped: 0, reseeded: 0. A caller such
as #64 cannot tell a gated reset from a completed one. It also cannot wait for the human. Two
options exist. Block on waitForReady here, as provision does. Or return a distinct
performedBy: 'human-gate' result that the caller must await. Which one do you intend?

Comment thread scripts/test-env-down.sh Outdated
COUCHDB_USER="${COUCHDB_USER:-medic}" COUCHDB_PASSWORD="${COUCHDB_PASSWORD:-password}" docker compose \
-f cht-couchdb.yml -f cht-core.yml -f "$OVERRIDE" down -v

echo "CHT environment torn down (-v removed volumes for a clean slate)."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): -v removes the named volumes only. cht-core's compose bind-mounts
CouchDB data to local-build/srv on the host, so the data survives this command. The message
promises a clean slate it does not give:

Suggested change
echo "CHT environment torn down (-v removed volumes for a clean slate)."
echo "CHT environment torn down (-v removed the named volumes; CouchDB data under local-build/srv is a bind mount and stays)."

Comment on lines +225 to +227
if (!options.chtCorePath && !options.version) {
throw new Error('provision requires either chtCorePath or version');
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): version passes this check, but the real path has no
published-version bring-up. It prints scripts/test-env-up.sh, which builds local images.
network is printed in the gate, but the scripts and the override hardcode cht-agent-net. Both
options read as supported and are not. Either implement them or reject them in real mode.

…ks, honest seams

Blocking:

- prepareTestData deletes only json_docs files it generated. A manifest beside json_docs
  records each csv-to-docs output with its sha256; a file that is not listed, or was edited
  since, is the operator's, and the seed refuses rather than touching it. Listing is
  recursive, as upload-docs reads it. With no csv/ input, json_docs is uploaded as-is.
- each cht-core checkout gets its own Compose project (cht-agent-<dir>-<path hash>,
  CHT_TEST_ENV_PROJECT overrides), and with it its own internal network, CouchDB bind mount
  and cert volume. Compose used to name every stack local-build, so down -v on one checkout
  hit another's. up refuses while another project's nginx is on cht-agent-net; down and
  restart fail on a project with no containers instead of reporting success. The three
  scripts share scripts/lib/test-env.sh.

Correctness:

- readiness asks for JSON: until cht-api is up it answers every other client with a 200
  startup page, which passed as healthy. Healthy now also means monitoring reports both an
  app and a CouchDB version. Redirects and TLS verification errors fail at once, the reason
  is logged when it changes, and the last probe keeps a real budget before the deadline.
- provision checks through /_session that the credentials are a CouchDB admin, since
  /api/v2/monitoring is unauthenticated and --force and _bulk_docs both need one
- cht-conf ERROR lines are matched after stripping ANSI, case-sensitively; the old /\bERROR\b/i
  missed real errors and failed benign lines such as "error-report.xml uploaded"
- a seed whose upload-docs summary falls short (e.g. re-seeding the same data) reports
  succeeded: false; csv-to-docs and upload-docs run as separate calls so protected ids are
  refused before anything is uploaded
- the couchdb reset refuses to wipe a doc json_docs cannot restore, and reads the wiped ids
  back from CouchDB after the reseed instead of trusting cht-conf's summary
- a relative dataPath is resolved once and tracked absolute; a subset reset counts only
  what it wiped; teardown keeps tracking, because the CouchDB bind mount survives down -v
- the spawn timeout escalates to SIGKILL, with the timer unref'd; signals reach only the
  direct child, which the comment now says
- buildChtConfArgs and runBucket share one lowering, so the argv specs test what spawns

Guards:

- the dashed-IP resolver answers for any address, so only private ranges pass; .local is
  gone (it is also the Active Directory suffix)
- migration-log, shortcode-id-length, privacy-policies, service-worker-meta and
  zscore-charts join the protected doc ids
- mock mode parses and strips credentials from options.url as the real path does, and an
  invalid URL no longer throws with the password on error.input

Seams that read as supported and were not:

- real mode refuses a published version: the scripts only build a working copy, so there
  was no bring-up to gate or poll
- a custom network is refused: the scripts and the override hardcode cht-agent-net
- COUCHDB_USER and COUCHDB_PASSWORD default independently, exactly as the scripts do
- the stack's cert is issued for nginx (COMMON_NAME), so NODE_EXTRA_CA_CERTS works for the
  agent's own fetch; host ports bind to 127.0.0.1 by default
- the provision wait is 30 minutes: a cold bring-up was measured at 17m49s
- CHT_CONF_PATH is gone from the prose and types; nothing read it
- down.sh, the runbook and the design doc no longer promise a clean slate from down -v

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread scripts/test-env-up.sh
# it leaves the patches unapplied and the build broken. This is why --ignore-scripts
# is not used here. The code being installed is the same tree we are about to build
# images from and run, so npm scripts add no privilege beyond what follows.
( cd "$TARGET" && npm ci )

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: This push fixes the three round-1 blockers and 25 of the other 26 round-1 comments. My inline comment on the reset docstring follows up the last one.

issue (blocking): Issue #66 item 3 asks for test data that conforms to the deployed config. This PR does not generate that data. Please change "Closes #66" to "Part of #66" and file the rest, or tell me which document is wrong.

note: The 42 inline comments do not block. All their suggestions apply together: tsc and eslint are clean, and all 1,208 tests pass. Four suggestions fix four of the 7 new SonarCloud issues. The other three flag intended code, and I will accept them. My round-1 follow-ups on CI, the PR text and the runbook are still open.

Comment on lines +11 to +12
# by hand, always pass a project name:
# docker compose -p <project> -f cht-couchdb.yml -f cht-core.yml -f cht-agent-net.override.yml up -d

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): This line asks only for a project name for a by-hand run, and that is not enough. The cht-core templates name the internal network ${CHT_NETWORK:-cht-net} (scripts/build/cht-core.yml.template:95-97), and Compose reuses another project's network of that name with only a warning. The cht-docs dev-environment page starts the generated cht-couchdb.yml with medic/password, the same credentials as these scripts, and with no CHT_NETWORK. So that CouchDB is on cht-net, with the service name couchdb. A by-hand stack then joins that network, and its haproxy, which defaults to couchdb, can reach that CouchDB. Two by-hand projects in one checkout also share the default ./srv data directory.

test_env_compose sets CHT_NETWORK and COUCHDB_DATA for this reason (scripts/lib/test-env.sh:51). The suggestion adds both values to the by-hand command, together with the other values that test_env_compose passes. Without COUCHDB_PASSWORD, Compose stops. With the template defaults, the admin is admin, not the agent's medic. The cert CN is also not nginx, the host that the agent dials.

Suggested change
# by hand, always pass a project name:
# docker compose -p <project> -f cht-couchdb.yml -f cht-core.yml -f cht-agent-net.override.yml up -d
# by hand, pass the same values that test_env_compose passes, not only -p. Without
# CHT_NETWORK, the stack shares the default `cht-net` network and the couchdb/haproxy/api
# names with any other CHT stack that also uses `cht-net`. Without COUCHDB_PASSWORD,
# Compose stops. Use this command:
# COUCHDB_USER=medic COUCHDB_PASSWORD=password COMMON_NAME=nginx \
# NGINX_HTTP_PORT=127.0.0.1:80 NGINX_HTTPS_PORT=127.0.0.1:443 \
# CHT_NETWORK=<project>-net COUCHDB_DATA=./srv-<project> \
# docker compose -p <project> -f cht-couchdb.yml -f cht-core.yml -f cht-agent-net.override.yml up -d

Comment on lines +137 to +139
> `scripts/test-env-up.sh` gives each checkout its own Compose project, internal
> network and CouchDB data dir, and refuses to start while another stack's nginx
> is on `cht-agent-net`. Overrides are listed in `scripts/lib/test-env.sh`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): Section 4 has the agent poll https://nginx, but it never says how the agent trusts the stack's self-signed cert. At 436d5fe, readiness treats a TLS verification error as terminal (src/utils/cht-readiness.ts:17-24, :47, :140-141). I probed a self-signed server on Node 22: fetch fails with DEPTH_ZERO_SELF_SIGNED_CERT, so provision() throws as soon as nginx answers. Node loads the NODE_EXTRA_CA_CERTS file once, no later than the first TLS call, so the cert must be in place before the run starts. docker/docker-compose.cht-agent.yml:46-53 also does not forward COUCHDB_USER or COUCHDB_PASSWORD.

No in-container command calls provision() yet, so this is a documentation gap, not a break on the main path. The suggestion adds the trust step and the credential pass-through in the order that works.

Suggested change
> `scripts/test-env-up.sh` gives each checkout its own Compose project, internal
> network and CouchDB data dir, and refuses to start while another stack's nginx
> is on `cht-agent-net`. Overrides are listed in `scripts/lib/test-env.sh`.
> `scripts/test-env-up.sh` gives each checkout its own Compose project, internal
> network and CouchDB data dir, and refuses to start while another stack's nginx
> is on `cht-agent-net`. Overrides are listed in `scripts/lib/test-env.sh`.
>
> The stack's cert is self-signed, and the agent's readiness poll stops at the first
> TLS verification error. So bring the stack up before the agent run starts, not
> after the agent asks: the poll then succeeds at once. Run the `docker cp` line
> that `test-env-up.sh` prints at its end, then copy that file into the container
> (`docker cp <file> cht-agent:/tmp/cht-cert.pem`). Start the run with
> `docker exec -e NODE_EXTRA_CA_CERTS=/tmp/cht-cert.pem ...`. After
> `test-env-down.sh`, the next bring-up makes a new cert. If the bring-up used
> non-default `COUCHDB_USER` or `COUCHDB_PASSWORD`, pass the same values with `docker exec -e`.

Comment thread scripts/lib/test-env.sh
test_env_select() {
local path="$1" base
TEST_ENV_TARGET="$(cd "$path" && pwd -P)"
base="$(basename "$TEST_ENV_TARGET" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9_-]/-/g' -e 's/^-*//')"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): With GNU sed and glibc, in many UTF-8 locales such as en_US.UTF-8 (but not C.UTF-8), [a-z] also matches lowercase accented letters. I sourced this lib under LANG=en_US.UTF-8 and ran test_env_select on a directory named café. It gave cht-agent-café-9aa5266e. Compose rejects any -p value that differs from its normalized form, which keeps only ASCII [a-z0-9_-] (compose-go v2.16.1 cli/options.go:120). So down and restart fail at the ps call on line 60. Up fails only at test-env-up.sh:103, after local-images and, when they run, npm ci and build-dev.

The suggestion runs tr and sed in the C locale. That gives cht-agent-caf---9aa5266e and keeps ASCII names unchanged.

Suggested change
base="$(basename "$TEST_ENV_TARGET" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9_-]/-/g' -e 's/^-*//')"
base="$(basename "$TEST_ENV_TARGET" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C sed -e 's/[^a-z0-9_-]/-/g' -e 's/^-*//')"

Comment thread scripts/lib/test-env.sh
Comment on lines +57 to +68
# restart and down would otherwise report success against a project with nothing in it.
test_env_require_containers() {
local containers
containers="$(test_env_compose ps -a -q)"
if [[ -z "$containers" ]]; then
echo "error: Compose project '$TEST_ENV_PROJECT' has no containers." >&2
echo " 'docker compose ls -a' lists the projects that exist; a stack started under" >&2
echo " another name needs CHT_TEST_ENV_PROJECT=<name> (earlier versions of these" >&2
echo " scripts left it to Compose, which named every stack 'local-build')." >&2
exit 1
fi
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): My round-1 suggestion added the CHT_TEST_ENV_PROJECT override, and this hardens it. Line 36 uses the override unchanged, and test_env_require_containers checks only that the project has containers. The error text at lines 63-65 points operators to local-build, the name Compose gives a stack in local-build/ without -p. With that name, down -v (test-env-down.sh:14) deletes another checkout's containers and named volumes, and up -d (test-env-up.sh:103) recreates its containers.

The suggestion refuses a project whose com.docker.compose.project.working_dir label names another directory, and it drops the local-build hint. I tested it with a stub docker. Down and restart still refuse an empty project and pass the owner. They now refuse a foreign or mixed project.

The label holds the path that Compose saw. So the check also refuses a stack that an operator started by hand through a symlinked path. The message names that path. My comment on scripts/test-env-up.sh:54 adds the same check there, so up refuses before the build.

Suggested change
# restart and down would otherwise report success against a project with nothing in it.
test_env_require_containers() {
local containers
containers="$(test_env_compose ps -a -q)"
if [[ -z "$containers" ]]; then
echo "error: Compose project '$TEST_ENV_PROJECT' has no containers." >&2
echo " 'docker compose ls -a' lists the projects that exist; a stack started under" >&2
echo " another name needs CHT_TEST_ENV_PROJECT=<name> (earlier versions of these" >&2
echo " scripts left it to Compose, which named every stack 'local-build')." >&2
exit 1
fi
}
# Refuse a project that another checkout started. With a shared CHT_TEST_ENV_PROJECT,
# up would recreate that checkout's containers, and down -v would delete them and its volumes.
test_env_require_owner() {
local dirs dir
dirs="$(docker ps -a --filter "label=com.docker.compose.project=$TEST_ENV_PROJECT" \
--format '{{.Label "com.docker.compose.project.working_dir"}}' | sort -u)"
while IFS= read -r dir; do
if [[ -n "$dir" && "$dir" != "$TEST_ENV_TARGET/local-build" ]]; then
echo "error: Compose project '$TEST_ENV_PROJECT' has containers from $dir, which is not $TEST_ENV_TARGET/local-build." >&2
exit 1
fi
done <<< "$dirs"
return 0
}
# restart and down would otherwise report success against a project with nothing in it.
test_env_require_containers() {
local containers
containers="$(test_env_compose ps -a -q)"
if [[ -z "$containers" ]]; then
echo "error: Compose project '$TEST_ENV_PROJECT' has no containers." >&2
echo " 'docker compose ls -a' lists the projects that exist; a stack this checkout" >&2
echo " started under another name needs CHT_TEST_ENV_PROJECT=<name>." >&2
exit 1
fi
test_env_require_owner
return 0
}

Comment thread scripts/test-env-up.sh
echo "error: cht-core path not found: $TARGET" >&2
exit 1
fi
test_env_select "$TARGET"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): This goes with my suggestion on scripts/lib/test-env.sh:57-68. up selects the project here, before the build. Without this call, a shared CHT_TEST_ENV_PROJECT still lets up -d (line 103) recreate the containers of another checkout. With it, up refuses a project that another checkout started. A new project passes, because test_env_require_owner checks only the containers that exist.

Suggested change
test_env_select "$TARGET"
test_env_select "$TARGET"
test_env_require_owner

* so it is refused; checking the release tag out in a working copy reaches the same code.
*/
const assertRealModeSupported = (options: ProvisionOptions, network: string): void => {
if (options.version !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): When a real-mode caller passes both chtCorePath and version, provision logs Source: local code (<path>) (:802-808). This check then throws check <version> out in a cht-core working copy and pass chtCorePath, but the caller already passed one. The refusal targets a request with only a published version, so it should test for a missing working copy.

With the suggestion, real mode ignores version when the caller also passes chtCorePath, as mock mode does. validateProvisionOptions (:260-263) requires version when chtCorePath is absent, so the message stays valid. If you want both options together to stay an error, as src/types/index.ts:973-974 implies, keep the condition and fix only the message. No spec covers this case.

Suggested change
if (options.version !== undefined) {
if (!options.chtCorePath) {

Comment thread src/types/index.ts
Comment on lines +938 to +948
/**
* Inputs to prepareTestData's real path. The data project is a cht-conf
* project folder: docs come from `<dataPath>/csv/*.csv` (csv-to-docs naming:
* place.<type>.csv, person.csv, report.<form>.csv, contact.csv, users.csv),
* and user accounts from `<dataPath>/users.csv` (hand-written, or generated
* by csv-to-docs from users.*.csv inputs). create-users only runs when that
* file exists — cht-conf throws on a missing users.csv.
*/
export interface PrepareTestDataOptions {
/** cht-conf project folder holding csv/ (required by the real path). */
dataPath?: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): This type doc still says docs come from <dataPath>/csv/*.csv and that dataPath is a folder holding csv/. At 436d5fe, prepareDocs also uploads a hand-authored json_docs when csv/ is absent (agent :635-651). With csv/, it refuses while json_docs holds a doc file it did not generate (agent :591-598). It also writes <dataPath>/.cht-agent-seeded.json into the operator's project (test-data.ts:24, :112). The type doc, the runbook and the design doc mention none of this.

The type doc also omits that cht-conf csv-to-docs replaces an existing <dataPath>/users.csv when the users CSV gives at least one user. The suggestion documents all of this, and the error at agent :955 should also say csv/ or json_docs/.

Suggested change
/**
* Inputs to prepareTestData's real path. The data project is a cht-conf
* project folder: docs come from `<dataPath>/csv/*.csv` (csv-to-docs naming:
* place.<type>.csv, person.csv, report.<form>.csv, contact.csv, users.csv),
* and user accounts from `<dataPath>/users.csv` (hand-written, or generated
* by csv-to-docs from users.*.csv inputs). create-users only runs when that
* file exists — cht-conf throws on a missing users.csv.
*/
export interface PrepareTestDataOptions {
/** cht-conf project folder holding csv/ (required by the real path). */
dataPath?: string;
/**
* Inputs to prepareTestData's real path. The data project is a cht-conf
* project folder. With a csv/ directory, csv-to-docs regenerates json_docs from
* `<dataPath>/csv/*.csv` (naming: place.<type>.csv, person.csv, report.<form>.csv,
* contact.csv, users.csv). The layer records the files it generated in
* `<dataPath>/.cht-agent-seeded.json` and refuses to seed while json_docs holds any
* other doc file. Without csv/, the layer uploads the hand-authored json_docs as it is.
* User accounts come from `<dataPath>/users.csv`. The operator writes that file by hand,
* or csv-to-docs writes it from csv/users.csv or csv/users.<name>.csv and replaces any
* existing file. create-users only runs when that file exists: cht-conf throws on a
* missing users.csv.
*/
export interface PrepareTestDataOptions {
/** cht-conf project folder with csv/ or a hand-authored json_docs/ (required in real mode). */
dataPath?: string;

expect(result.warnings.join(' ')).to.include('only 3 of 5 docs uploaded');
});

it('should warn when json_docs ends up empty (no csv inputs)', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): uploadShortfall marks a seed as failed in two cases: a short summary, or no summary for a seed with docs. Only the first case has a test. I silenced the second branch with docCount > 0 && false, and all 113 agent specs still pass. The cht-conf 6.6.1 CLI logs the summary line (upload-docs.js:60) whenever it exits 0 with docs to upload, unless it runs with --silent (main.js:120-121). The agent never passes --silent, so a missing summary is a real failure signal. The suggested test passes at 436d5fe and fails on that change.

Suggested change
it('should warn when json_docs ends up empty (no csv inputs)', async () => {
it('should report succeeded:false when upload-docs exits 0 without a summary for docs it was given', async () => {
runs['upload-docs'] = okRun(ansiInfo('Uploading docs'));
const result = await realAgent.prepareTestData(dockerHandle, sampleConfig, { dataPath });
expect(result.succeeded).to.equal(false);
expect(result.warnings.join(' ')).to.include('upload-docs printed no summary for 5 doc(s)');
});
it('should warn when json_docs ends up empty (no csv inputs)', async () => {

const reseedCall = runChtConfStub.firstCall.args[0];
expect(reseedCall.verbs).to.deep.equal(['upload-docs']);
expect(reseedCall.configPath).to.equal(dataPath);
expect(reseedCall.cwd).to.equal(dataPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): The commit says the reset reads the wiped ids back after the reseed, but no test checks that order. This test counts two fetchDocRevs calls (:1286) but not when the second one ran, and the default stub returns the same rows for both reads. I swapped the reseed and the verify step at test-environment-agent.ts:1065-1066, and all 113 agent specs still passed. The suggestion asserts that the second read comes after the reseed. seedTracking resets the runChtConfStub history, so its first call is the reseed. The new line passes at 436d5fe and fails with the swap.

Suggested change
expect(reseedCall.cwd).to.equal(dataPath);
expect(reseedCall.cwd).to.equal(dataPath);
expect(fetchDocRevsStub.secondCall.calledAfter(runChtConfStub.firstCall)).to.equal(true);

const error = await expectRejection(waitForReady('https://nginx', { ...fast, maxWaitMs: 5 }));

expect(error.message).to.include('ECONNREFUSED');
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Both tests that set a cause give it a message. I checked on Node 22, with localhost mapped to both ::1 and 127.0.0.1. There, fetch('https://localhost:59997/x') rejects with an AggregateError cause that has an empty message and code ECONNREFUSED. Only || cause?.code at src/utils/cht-readiness.ts:43 turns that into a useful reason. CHT_URL=https://localhost is a real override, so this case matters.

I changed || to ??, and all 12 tests still pass. The suggested test fails on that change and passes on the current code.

Suggested change
});
});
it('names the cause code when its message is empty (an AggregateError for localhost)', async () => {
const cause = Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' });
fetchStub.rejects(Object.assign(new TypeError('fetch failed'), { cause }));
const error = await expectRejection(waitForReady('https://localhost', { ...fast, maxWaitMs: 5 }));
expect(error.message).to.include('fetch failed (ECONNREFUSED)');
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

Implement Test Environment Layer

3 participants