Tests, licence, and the tooling to keep the repo consistent
A test suite with no dependencies beyond bash and the Python standard library, run by one command: scripts/test.sh (or: make test) scripts/test.sh --windows [host] adds the PowerShell suite over SSH - tests/static.sh: script syntax, JSON validity, VERSION against the widget manifest and CHANGELOG, providers.json against the kinds, probes and checks the code implements (and ids that would shadow a command), qmllint and shellcheck when installed. - tests/python: unit tests for cm-json.py - providers and the column contract bash depends on, the original three scaffolds byte for byte, every catalogue parser and the cache, session scan/dismiss/repair, rename, defaults, health, the cost guard. - tests/cli: the CLI in a sandbox with its own HOME, CM_ROOT, CLAUDE_CONFIG_DIR and a file-only vault, against fake Ollama, LM Studio, keyed-gateway and proxy servers - preflight, presets, models, doctor, a real switch, sessions, and the key helper under a rename race. - tests/windows: the same idea on a Windows host, with USERPROFILE pointed at a temp folder so the real install is never touched. Also: MIT licence, CHANGELOG.md reconstructed from history, .editorconfig and .gitattributes pinning LF everywhere (what the tree already is), and scripts/bump-version.sh, since the version lives in two files and drifted once before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# doctor on a server provider: model ids against the catalogue, and Ollama's
|
||||
# server-side context window.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
set_state ollama ollama
|
||||
run_cm doctor
|
||||
expect_out "Ollama reachable at $OLLAMA_URL" 'the server is found'
|
||||
expect_out 'opus qwen3-coder [30.5B Q4_K_M]' 'a bare name matches its :latest tag'
|
||||
expect_no_out 'NOT available' 'no model is reported missing'
|
||||
expect_out 'loaded with a 4096-token context, below the declared 65536' 'the short server context is caught'
|
||||
expect_out 'OLLAMA_CONTEXT_LENGTH=65536' 'and the fix is named'
|
||||
|
||||
ready_preset lmstudio "$LMSTUDIO_URL"
|
||||
python3 "$J" set-all "$P/lmstudio.json" gemma-small >/dev/null
|
||||
set_state lmstudio lmstudio
|
||||
run_cm doctor
|
||||
expect_out 'gemma-small [not-loaded, ctx 8192]' 'lm studio models show their state'
|
||||
expect_out 'below the declared 262144 - this tier can overflow' 'a small model window is warned about'
|
||||
|
||||
python3 "$J" set-url "$P/lmstudio.json" http://127.0.0.1:1 >/dev/null
|
||||
run_cm doctor
|
||||
expect_out 'LM Studio not reachable' 'a dead server is a failure'
|
||||
|
||||
finish
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# The key helper Claude Code runs on a timer in every live session.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
H="$CM_ROOT/bin/claude-key-helper.sh"
|
||||
|
||||
helper() { OUT="$("$H" 2>&1)"; RC=$?; }
|
||||
|
||||
set_state anthropic
|
||||
helper
|
||||
expect_eq "$RC:$OUT" '0:' 'silent on anthropic'
|
||||
set_state lmstudio lmstudio
|
||||
helper
|
||||
expect_eq "$RC:$OUT" '0:' 'silent for an inline token'
|
||||
set_state lmstudio nosuch
|
||||
helper
|
||||
expect_rc 1 'a missing preset fails'
|
||||
expect_out 'does not exist' 'and says which'
|
||||
|
||||
"$CM" set-key zai sk-test-0123456789abcdef >/dev/null 2>&1
|
||||
set_state zai zai
|
||||
helper
|
||||
expect_eq "$OUT" sk-test-0123456789abcdef 'emits the vault key, and nothing else'
|
||||
|
||||
# Renaming the active preset while the helper loops: no fetch may see a gap.
|
||||
cp "$P/lmstudio.json" "$P/racer.json"
|
||||
set_state lmstudio racer
|
||||
(
|
||||
misses=0
|
||||
end=$((SECONDS + 4))
|
||||
while [ "$SECONDS" -lt "$end" ]; do
|
||||
"$H" >/dev/null 2>"$SB/helper.err" || { grep -q 'does not exist' "$SB/helper.err" && misses=$((misses + 1)); }
|
||||
done
|
||||
echo "$misses" > "$SB/misses"
|
||||
) &
|
||||
loop=$!
|
||||
cur=racer
|
||||
for i in $(seq 1 30); do
|
||||
"$CM" preset rename "$cur" "racer$i" >/dev/null 2>&1
|
||||
cur="racer$i"
|
||||
done
|
||||
wait "$loop"
|
||||
expect_eq "$(cat "$SB/misses")" 0 'no key fetch is lost to a rename'
|
||||
expect_eq "$(jget "$CM_ROOT/state.json" preset)" racer30 'state ends on the last name'
|
||||
|
||||
finish
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Model catalogues: fetched by each provider's kind, cached per server.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
C="$CM_ROOT/models-cache.json"
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
run_cm models --preset ollama
|
||||
expect_out 'qwen3-coder:latest' 'ollama models are listed'
|
||||
run_cm models --preset ollama --refresh
|
||||
expect_out 'cached 2 ollama model(s)' 'and cached'
|
||||
expect_eq "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["providers"]["ollama"]["baseUrl"])' "$C")" \
|
||||
"$OLLAMA_URL" 'against the server they came from'
|
||||
|
||||
ready_preset lmstudio "$LMSTUDIO_URL"
|
||||
run_cm models --preset lmstudio
|
||||
expect_out 'qwen3-coder-30b' 'lm studio models are listed'
|
||||
|
||||
python3 - "$P/custom.json" "$KEYED_URL" <<'PY'
|
||||
import json, sys
|
||||
p = json.load(open(sys.argv[1]))
|
||||
p.update(baseUrl=sys.argv[2], auth={"mode": "literal", "token": "sk-real"}, configured=True)
|
||||
json.dump(p, open(sys.argv[1], "w"), indent=2)
|
||||
PY
|
||||
run_cm models --preset custom
|
||||
expect_out 'gw/model-b' 'a keyed endpoint lists its models with the key'
|
||||
|
||||
cp "$P/custom.json" "$P/proxy.json"
|
||||
python3 "$J" set-url "$P/proxy.json" "$PROXY_URL" >/dev/null
|
||||
run_cm models --preset proxy --refresh
|
||||
expect_rc 1 'a proxy has no list to fetch'
|
||||
expect_out 'model ids can still be typed by hand' 'and that is explained'
|
||||
|
||||
run_cm models --preset zai
|
||||
expect_out 'glm-5.3' 'the static zai list'
|
||||
run_cm models --preset zai --json
|
||||
expect_out '"glm-4.7"' '--json prints the cached node'
|
||||
|
||||
run_cm models --preset nope --refresh
|
||||
expect_rc 1 'an unknown preset is refused'
|
||||
|
||||
for word in keyRef token auth; do
|
||||
case "$(cat "$C")" in *"\"$word\""*) fail "the cache holds a $word" ;; *) pass ;; esac
|
||||
done
|
||||
|
||||
finish
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preflight: what refuses a switch, and why - against fake servers.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
|
||||
code() { preflight_code "$@" | cut -d' ' -f1; }
|
||||
|
||||
expect_eq "$(code ollama)" needs-setup 'a shipped preset is set up before use'
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
expect_eq "$(code ollama)" ok 'ollama answers'
|
||||
cp "$P/ollama.json" "$P/dead.json"
|
||||
python3 "$J" set-url "$P/dead.json" http://127.0.0.1:1 >/dev/null
|
||||
expect_eq "$(code ollama dead)" server-unreachable 'a dead ollama is refused'
|
||||
|
||||
ready_preset lmstudio "$LMSTUDIO_URL"
|
||||
expect_eq "$(code lmstudio)" ok 'lm studio answers'
|
||||
|
||||
# custom: every tier empty first, then no address, then the key, then a proxy
|
||||
ready_preset custom
|
||||
expect_eq "$(preflight_code custom)" 'no-models edit-preset' 'a preset with no models is refused'
|
||||
python3 "$J" set-tier "$P/custom.json" opus gw/model-a
|
||||
expect_eq "$(preflight_code custom)" 'no-url set-url' 'a custom endpoint needs an address'
|
||||
python3 "$J" set-url "$P/custom.json" "$KEYED_URL" >/dev/null
|
||||
python3 "$J" set-auth "$P/custom.json" none >/dev/null
|
||||
expect_eq "$(code custom)" server-auth 'a keyed endpoint refuses the placeholder'
|
||||
cp "$P/custom.json" "$P/proxy.json"
|
||||
python3 "$J" set-url "$P/proxy.json" "$PROXY_URL" >/dev/null
|
||||
expect_eq "$(code custom proxy)" ok 'a proxy with no model list passes the lenient probe'
|
||||
|
||||
run_cm preset new b1 --provider openrouter --blank
|
||||
expect_eq "$(code openrouter b1)" no-models 'a blank preset cannot be switched to'
|
||||
|
||||
finish
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preset lifecycle: names, new/rename/rm, and the per-provider default.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
run_cm preset show ../../etc/passwd
|
||||
expect_rc 1 'a path is not a preset name'
|
||||
expect_out 'invalid preset name' 'and it says why'
|
||||
run_cm preset new .hidden
|
||||
expect_rc 1 'a leading dot is refused'
|
||||
run_cm preset new a/b
|
||||
expect_rc 1 'a slash is refused'
|
||||
|
||||
run_cm preset new cheap
|
||||
expect_rc 0 'new copies default'
|
||||
expect_eq "$(jget "$P/cheap.json" provider)" openrouter 'the copy keeps the provider'
|
||||
run_cm preset new z2 --provider zai
|
||||
expect_out "from 'zai'" "--provider copies that provider's default"
|
||||
run_cm preset new z3 --provider zai default
|
||||
expect_rc 1 'copying across providers is refused'
|
||||
run_cm preset new b1 --provider ollama --blank
|
||||
expect_eq "$(jget "$P/b1.json" auth.token)" ollama "a blank preset carries its provider's token"
|
||||
run_cm preset new b2 --blank
|
||||
expect_rc 1 '--blank needs --provider'
|
||||
run_cm preset new x --provider foo
|
||||
expect_rc 1 'an unknown provider is refused'
|
||||
run_cm preset set nope opus x
|
||||
expect_rc 1 'setting a tier on a missing preset is refused'
|
||||
expect_no_file "$P/nope.json" 'and does not create it'
|
||||
|
||||
run_cm preset rename cheap cheap2
|
||||
expect_file "$P/cheap2.json" 'rename moves the file'
|
||||
expect_no_file "$P/cheap.json" 'and removes the old name'
|
||||
run_cm preset rename cheap2 default
|
||||
expect_rc 1 'renaming onto an existing preset is refused'
|
||||
set_state openrouter cheap2
|
||||
run_cm preset rename cheap2 cheap3
|
||||
expect_eq "$(jget "$CM_ROOT/state.json" preset)" cheap3 'state follows the active preset'
|
||||
run_cm preset rm cheap3
|
||||
expect_rc 1 'the active preset cannot be deleted'
|
||||
set_state anthropic
|
||||
|
||||
run_cm preset default openrouter cheap3
|
||||
expect_eq "$("$CM" preset default openrouter)" cheap3 'a chosen default is used'
|
||||
run_cm preset rename cheap3 cheap4
|
||||
expect_eq "$(jget "$CM_ROOT/defaults.json" openrouter)" cheap4 'the choice follows a rename'
|
||||
run_cm preset rm cheap4
|
||||
expect_eq "$(jget "$CM_ROOT/defaults.json" openrouter)" '' 'deleting clears the choice'
|
||||
expect_eq "$("$CM" preset default openrouter)" default 'and the built-in applies again'
|
||||
run_cm preset default openrouter zai
|
||||
expect_rc 1 "a default from another provider is refused"
|
||||
|
||||
"$CM" preset new c5 >/dev/null 2>&1
|
||||
"$CM" preset default openrouter c5 >/dev/null 2>&1
|
||||
rm -f "$P/c5.json"
|
||||
expect_eq "$("$CM" preset default openrouter)" default 'a vanished choice falls back'
|
||||
|
||||
run_cm preset rm z2
|
||||
run_cm preset rm zai
|
||||
expect_out 'that was the last zai preset' 'removing the last preset warns'
|
||||
|
||||
finish
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Every provider in providers.json is a mode: listed, dispatched, aliased.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
run_cm --help
|
||||
for id in openrouter zai lmstudio ollama custom; do
|
||||
expect_out "claude-mode $id [preset]" "usage lists $id"
|
||||
done
|
||||
|
||||
run_cm presets
|
||||
expect_out 'ollama' 'presets lists ollama'
|
||||
expect_out 'custom' 'presets lists custom'
|
||||
|
||||
for word in zai Z.AI z-ai lm-studio OLLAMA; do
|
||||
code="$(preflight_code "$word")"
|
||||
expect_eq "${code%% *}" needs-setup "preflight resolves '$word'"
|
||||
done
|
||||
|
||||
run_cm nope
|
||||
expect_rc 1 'an unknown command fails'
|
||||
expect_out "unknown command 'nope'" 'and says so'
|
||||
|
||||
run_cm preset default
|
||||
expect_out 'ollama ollama (built-in)' 'ollama has a built-in default'
|
||||
expect_out 'custom custom (built-in)' 'custom has a built-in default'
|
||||
|
||||
finish
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Broken transcripts: the scan, dismissing and restoring, the age rule, repair.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
proj="$CLAUDE_CONFIG_DIR/projects/-tmp-fixture"
|
||||
mkdir -p "$proj"
|
||||
transcript() { # <session-id> [age-in-hours]
|
||||
printf '%s\n' \
|
||||
'{"type":"assistant","message":{"id":"msg_1","model":"claude-opus-5","content":[{"type":"text","text":"hi"}]}}' \
|
||||
'{"type":"assistant","message":{"id":"gen-1-a","model":"deepseek/x","content":[{"type":"text","text":"yo"}]}}' \
|
||||
> "$proj/$1.jsonl"
|
||||
python3 -c 'import os, sys, time; t = time.time() - float(sys.argv[2]) * 3600; os.utime(sys.argv[1], (t, t))' \
|
||||
"$proj/$1.jsonl" "${2:-0}"
|
||||
}
|
||||
transcript aaaa-new
|
||||
transcript bbbb-old 240
|
||||
transcript dddd-fix 1
|
||||
printf '%s\n' '{"type":"assistant","message":{"id":"msg_1"}}' > "$proj/cccc-ok.jsonl"
|
||||
|
||||
ids() { # <broken|ignored> - the session ids in that list of the scan
|
||||
"$CM" repair-session --json "${@:2}" | python3 -c '
|
||||
import json, sys
|
||||
print(" ".join(x["sessionId"] for x in json.load(sys.stdin)[sys.argv[1]]))' "$1"
|
||||
}
|
||||
|
||||
expect_eq "$(ids broken)" 'aaaa-new dddd-fix' 'broken transcripts are found'
|
||||
expect_eq "$(ids ignored)" 'bbbb-old' 'one untouched for 10 days is hidden'
|
||||
expect_eq "$(ids broken --max-age 0)" 'aaaa-new bbbb-old dddd-fix' '--max-age 0 shows all'
|
||||
|
||||
run_cm repair-session --ignore aaaa-new
|
||||
expect_out 'hidden aaaa-new' 'a session is dismissed'
|
||||
expect_eq "$(ids broken)" 'dddd-fix' 'and leaves the broken list'
|
||||
run_cm repair-session --all
|
||||
expect_out '2 hidden: 1 ignored, 1 older than 7 days' 'hidden sessions are always counted'
|
||||
run_cm repair-session --ignored
|
||||
expect_out 'still broken' 'the dismissed list reconciles against the disk'
|
||||
run_cm repair-session --ignore zzzz
|
||||
expect_rc 1 'an unknown session cannot be dismissed'
|
||||
run_cm repair-session --unignore aaaa-new
|
||||
expect_eq "$(ids broken)" 'aaaa-new dddd-fix' 'restoring brings it back'
|
||||
|
||||
run_cm repair-session --ignore dddd-fix
|
||||
run_cm repair-session dddd-fix --apply
|
||||
expect_rc 0 'a broken session is repaired'
|
||||
expect_eq "$(ls "$proj" | grep -c 'dddd-fix.jsonl.pre-repair-backup')" 1 'the original is backed up'
|
||||
expect_eq "$(ids ignored)" 'bbbb-old' 'a repair clears its dismissal'
|
||||
|
||||
run_cm repair-session --ignore bbbb-old
|
||||
rm -f "$proj/bbbb-old.jsonl"
|
||||
run_cm repair-session --ignore aaaa-new
|
||||
expect_out 'forgot 1 whose transcript no longer exists' 'entries for deleted transcripts are pruned'
|
||||
run_cm repair-session --unignore-all
|
||||
expect_out 'restored every dismissed session' 'unignore-all empties the list'
|
||||
|
||||
finish
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# A real switch - into the sandbox's own settings.json - and editing the preset
|
||||
# in use. --yes because the machine running the tests may well have Claude Code
|
||||
# sessions open; with --yes the CLI proceeds and leaves them alone.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
S="$CLAUDE_CONFIG_DIR/settings.json"
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
run_cm ollama --yes
|
||||
expect_rc 0 'switch to ollama'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_BASE_URL)" "$OLLAMA_URL" 'base url written'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_AUTH_TOKEN)" ollama 'placeholder token written'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_DEFAULT_OPUS_MODEL)" qwen3-coder 'tier mapped'
|
||||
expect_eq "$(jget "$S" env.CLAUDE_CODE_MAX_CONTEXT_TOKENS)" 65536 'context declared'
|
||||
expect_eq "$(jget "$S" env.CLAUDE_CODE_ATTRIBUTION_HEADER)" 0 'extra env written'
|
||||
expect_eq "$(jget "$CM_ROOT/state.json" mode)" ollama 'state records the mode'
|
||||
expect_eq "$(jget "$CM_ROOT/health.json" preset)" ollama 'health.json follows'
|
||||
|
||||
run_cm anthropic --yes
|
||||
expect_rc 0 'switch back to anthropic'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_BASE_URL)" '' 'gateway env removed'
|
||||
expect_eq "$(jget "$S" env.CLAUDE_CODE_ATTRIBUTION_HEADER)" '' 'including the extra env'
|
||||
|
||||
# The preset in use: a tier edit re-applies without asking about sessions.
|
||||
set_state openrouter default
|
||||
run_cm preset set default sonnet test/model-c --force
|
||||
expect_rc 0 'a tier edit on the active preset re-applies'
|
||||
expect_no_out 'refusing to switch while sessions are running' 'without the sessions prompt'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_DEFAULT_SONNET_MODEL)" test/model-c 'and reaches settings.json'
|
||||
|
||||
run_cm preset set default opus anthropic/claude-opus-5 --force
|
||||
expect_rc 1 'the cost guard still refuses an Anthropic model'
|
||||
expect_out 'saved, but re-applying the active preset failed' 'and says the edit was saved'
|
||||
|
||||
finish
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fake provider servers for the CLI and Windows tests, all on 127.0.0.1.
|
||||
|
||||
ollama GET /api/tags tagged names only, as the real server lists them
|
||||
GET /api/ps qwen3-coder:latest loaded with a 4096-token context
|
||||
POST /api/show a 262144-token maximum
|
||||
lmstudio GET /api/v0/models
|
||||
keyed GET /v1/models, only with `Bearer sk-real` or `x-api-key: sk-real`
|
||||
proxy 404 for everything - a Messages-only proxy with no model list
|
||||
|
||||
Ports are chosen by the OS. The first line printed is a JSON map of kind to
|
||||
port, which the harness reads before running anything.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def send(self, code, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
kind, path = self.server.kind, self.path
|
||||
if kind == "ollama" and path == "/api/tags":
|
||||
return self.send(200, {"models": [
|
||||
{"name": "qwen3-coder:latest", "model": "qwen3-coder:latest",
|
||||
"details": {"parameter_size": "30.5B", "quantization_level": "Q4_K_M", "family": "qwen3moe"}},
|
||||
{"name": "glm-4.7:cloud", "model": "glm-4.7:cloud", "details": {}}]})
|
||||
if kind == "ollama" and path == "/api/ps":
|
||||
return self.send(200, {"models": [
|
||||
{"name": "qwen3-coder:latest", "model": "qwen3-coder:latest", "context_length": 4096}]})
|
||||
if kind == "lmstudio" and path == "/api/v0/models":
|
||||
return self.send(200, {"data": [
|
||||
{"id": "qwen3-coder-30b", "state": "loaded", "max_context_length": 262144},
|
||||
{"id": "gemma-small", "state": "not-loaded", "max_context_length": 8192}]})
|
||||
if kind == "keyed" and path == "/v1/models":
|
||||
if self.headers.get("authorization") == "Bearer sk-real" or self.headers.get("x-api-key") == "sk-real":
|
||||
return self.send(200, {"data": [{"id": "gw/model-a"}, {"id": "gw/model-b"}]})
|
||||
return self.send(401, {"error": "unauthorized"})
|
||||
return self.send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers.get("content-length") or 0))
|
||||
if self.server.kind == "ollama" and self.path == "/api/show":
|
||||
return self.send(200, {"model_info": {"general.architecture": "qwen3moe",
|
||||
"qwen3moe.context_length": 262144}})
|
||||
return self.send(404, {"error": "not found"})
|
||||
|
||||
|
||||
def main():
|
||||
ports = {}
|
||||
for kind in ("ollama", "lmstudio", "keyed", "proxy"):
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
server.kind = kind
|
||||
ports[kind] = server.server_address[1]
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
print(json.dumps(ports), flush=True)
|
||||
threading.Event().wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
# Shared by tests/cli/test_*.sh - source it first.
|
||||
#
|
||||
# Sourcing gives the test its own sandbox: a HOME, CM_ROOT and CLAUDE_CONFIG_DIR
|
||||
# of its own, the repository's scripts installed into it the way linux/install.sh
|
||||
# lays them out, and the vault forced to the plain-file backend. Nothing on the
|
||||
# machine running the tests is read or written - not the real keyring, not
|
||||
# ~/.claude-mode, not ~/.claude/settings.json - and a switch performed by a test
|
||||
# only rewrites the sandbox's settings.json.
|
||||
#
|
||||
# Assertions count rather than stop, so one run reports every failure; the
|
||||
# file ends with `finish`, whose status is the test's.
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
T_NAME="$(basename "$0" .sh)"
|
||||
T_PASS=0
|
||||
T_FAIL=0
|
||||
OUT=''
|
||||
RC=0
|
||||
FAKE_PID=''
|
||||
|
||||
SB="$(mktemp -d "${TMPDIR:-/tmp}/cm-test.XXXXXX")"
|
||||
mkdir -p "$SB/root/bin" "$SB/root/presets" "$SB/home/.claude/projects"
|
||||
cp "$REPO/linux/claude-mode" "$REPO/linux/cm-json.py" "$REPO/linux/cm-vault.sh" \
|
||||
"$REPO/linux/claude-key-helper.sh" "$SB/root/bin/"
|
||||
if [ -d "$REPO/linux/lib" ]; then cp -R "$REPO/linux/lib" "$SB/root/bin/"; fi
|
||||
chmod +x "$SB/root/bin/claude-mode" "$SB/root/bin/claude-key-helper.sh"
|
||||
cp "$REPO"/presets/*.json "$SB/root/presets/"
|
||||
cp "$REPO/providers.json" "$REPO/VERSION" "$SB/root/"
|
||||
|
||||
export HOME="$SB/home"
|
||||
export CM_ROOT="$SB/root"
|
||||
export CLAUDE_CONFIG_DIR="$SB/home/.claude"
|
||||
export CLAUDE_MODE_VAULT=file
|
||||
unset CM_IGNORE_AGE_DAYS CLAUDE_MODE_THEME
|
||||
|
||||
CM="$SB/root/bin/claude-mode"
|
||||
J="$SB/root/bin/cm-json.py"
|
||||
P="$SB/root/presets"
|
||||
|
||||
set_state() {
|
||||
printf '{"mode":"%s","preset":"%s","writtenEnvKeys":[]}\n' "$1" "${2:-}" > "$CM_ROOT/state.json"
|
||||
}
|
||||
set_state anthropic
|
||||
|
||||
t_cleanup() {
|
||||
if [ -n "$FAKE_PID" ]; then kill "$FAKE_PID" 2>/dev/null; fi
|
||||
rm -rf "$SB"
|
||||
}
|
||||
trap t_cleanup EXIT
|
||||
|
||||
# Starts tests/fake_server.py and sets OLLAMA_URL, LMSTUDIO_URL, KEYED_URL and
|
||||
# PROXY_URL from the ports it chose.
|
||||
start_fake() {
|
||||
python3 "$REPO/tests/fake_server.py" > "$SB/ports" 2>/dev/null &
|
||||
FAKE_PID=$!
|
||||
local i
|
||||
for i in $(seq 1 50); do
|
||||
[ -s "$SB/ports" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -s "$SB/ports" ] || { echo "$T_NAME: the fake server did not start" >&2; exit 1; }
|
||||
eval "$(python3 -c '
|
||||
import json, sys
|
||||
for kind, port in json.load(open(sys.argv[1])).items():
|
||||
print("%s_URL=http://127.0.0.1:%d" % (kind.upper(), port))
|
||||
' "$SB/ports")"
|
||||
}
|
||||
|
||||
ESC="$(printf '\033')"
|
||||
|
||||
# run_cm <args...>: the CLI's combined output, colour codes removed, in OUT;
|
||||
# its exit status in RC.
|
||||
run_cm() {
|
||||
OUT="$("$CM" "$@" 2>&1)"
|
||||
RC=$?
|
||||
OUT="$(printf '%s' "$OUT" | sed "s/${ESC}\[[0-9;]*m//g")"
|
||||
}
|
||||
|
||||
pass() { T_PASS=$((T_PASS + 1)); }
|
||||
|
||||
fail() {
|
||||
T_FAIL=$((T_FAIL + 1))
|
||||
printf ' FAIL %s: %s\n' "$T_NAME" "$*"
|
||||
if [ -n "$OUT" ]; then printf '%s\n' "$OUT" | tail -n 8 | sed 's/^/ | /'; fi
|
||||
}
|
||||
|
||||
expect_rc() { if [ "$RC" -eq "$1" ]; then pass; else fail "$2 (exit $RC, wanted $1)"; fi; }
|
||||
expect_out() { case "$OUT" in *"$1"*) pass ;; *) fail "$2 (output lacks: $1)" ;; esac; }
|
||||
expect_no_out() { case "$OUT" in *"$1"*) fail "$2 (output has: $1)" ;; *) pass ;; esac; }
|
||||
expect_eq() { if [ "$1" = "$2" ]; then pass; else fail "$3 (got '$1', wanted '$2')"; fi; }
|
||||
expect_file() { if [ -e "$1" ]; then pass; else fail "$2 (no $1)"; fi; }
|
||||
expect_no_file(){ if [ -e "$1" ]; then fail "$2 ($1 exists)"; else pass; fi; }
|
||||
|
||||
jget() { python3 "$J" get "$1" "$2"; }
|
||||
|
||||
# The preflight verdict for a mode (and preset) as "<code> <remedyKind>".
|
||||
preflight_code() {
|
||||
"$CM" preflight "$@" 2>/dev/null | python3 -c '
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
print(d["code"], d["remedyKind"])'
|
||||
}
|
||||
|
||||
# Point a shipped preset at a fake server and mark it set up, so preflight
|
||||
# gets past "not set up yet" to the checks under test.
|
||||
ready_preset() {
|
||||
python3 "$J" set-flag "$P/$1.json" configured true
|
||||
[ -n "${2:-}" ] && python3 "$J" set-url "$P/$1.json" "$2" >/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
finish() {
|
||||
printf '%-22s %3d passed' "$T_NAME" "$T_PASS"
|
||||
if [ "$T_FAIL" -gt 0 ]; then printf ', %d FAILED' "$T_FAIL"; fi
|
||||
printf '\n'
|
||||
[ "$T_FAIL" -eq 0 ]
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Unit tests for linux/cm-json.py - the JSON engine behind the POSIX CLI.
|
||||
|
||||
Standard library only. cm-json.py is loaded from the repository, so it reads the
|
||||
repository's providers.json (it looks one level above itself). Every test that
|
||||
writes works in a temporary directory.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_spec = importlib.util.spec_from_file_location("cm_json", os.path.join(REPO, "linux", "cm-json.py"))
|
||||
cm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(cm)
|
||||
|
||||
|
||||
def call(fn, argv, stdin=""):
|
||||
"""Run a cm-json command function; return what it printed."""
|
||||
out, old = io.StringIO(), sys.stdin
|
||||
sys.stdin = io.StringIO(stdin)
|
||||
try:
|
||||
with contextlib.redirect_stdout(out):
|
||||
fn(argv)
|
||||
finally:
|
||||
sys.stdin = old
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def call_json(fn, argv, stdin=""):
|
||||
return json.loads(call(fn, argv, stdin))
|
||||
|
||||
|
||||
class TempDir(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="cm-json-test.")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.tmp, *parts)
|
||||
|
||||
def write_json(self, rel, data):
|
||||
p = self.path(rel)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# providers.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Providers(unittest.TestCase):
|
||||
def test_order(self):
|
||||
self.assertEqual([p["id"] for p in cm.providers()],
|
||||
["openrouter", "zai", "lmstudio", "ollama", "custom"])
|
||||
|
||||
def test_builtin_defaults(self):
|
||||
self.assertEqual(cm.builtin_default_preset(), {
|
||||
"openrouter": "default", "zai": "zai", "lmstudio": "lmstudio",
|
||||
"ollama": "ollama", "custom": "custom"})
|
||||
|
||||
def test_tsv_column_contract(self):
|
||||
# The bash CLI addresses these columns by number (see prov_field in
|
||||
# linux/claude-mode). Reordering them silently breaks it; append only.
|
||||
self.assertEqual([name for name, _ in cm.PROVIDER_TSV], [
|
||||
"id", "aliases", "title", "label", "color", "defaultPreset",
|
||||
"serverEditable", "probe", "probePaths", "catalogueKind", "perServer",
|
||||
"setupKey", "setupModels", "keyUrl", "guardrail", "doctor",
|
||||
"defaultKeyRef", "literalToken", "defaultBaseUrl", "serverHint", "serverStart"])
|
||||
for line in call(cm.cmd_provider_tsv, []).splitlines():
|
||||
self.assertEqual(len(line.split("\t")), len(cm.PROVIDER_TSV), line)
|
||||
|
||||
def test_resolve(self):
|
||||
for word, want in [("zai", "zai"), ("Z.AI", "zai"), ("z-ai", "zai"),
|
||||
("lm-studio", "lmstudio"), ("OLLAMA", "ollama")]:
|
||||
self.assertEqual(call(cm.cmd_provider_resolve, [word]).strip(), want)
|
||||
for word in ("anthropic", "nope", ""):
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_provider_resolve, [word])
|
||||
|
||||
def test_static_catalogue(self):
|
||||
self.assertEqual(call(cm.cmd_provider_static, ["zai"]).splitlines()[0].split("\t")[0], "glm-5.3")
|
||||
|
||||
|
||||
class Scaffold(unittest.TestCase):
|
||||
def blank(self, **over):
|
||||
base = {"provider": None, "description": "new preset", "baseUrl": None, "auth": None,
|
||||
"models": {t: "" for t in cm.TIERS}, "subagentModel": "inherit",
|
||||
"gatewayModelDiscovery": False, "contextTokens": None}
|
||||
base.update(over)
|
||||
return json.dumps(base, indent=2) + "\n"
|
||||
|
||||
# The original three, exactly as the hard-coded scaffold produced them
|
||||
# before providers.json existed.
|
||||
def test_openrouter(self):
|
||||
self.assertEqual(call(cm.cmd_scaffold, ["openrouter"]), self.blank(
|
||||
provider="openrouter", baseUrl="https://openrouter.ai/api",
|
||||
auth={"mode": "vault", "keyRef": "openrouter"}, gatewayModelDiscovery=True,
|
||||
contextTokens=1000000))
|
||||
|
||||
def test_zai(self):
|
||||
want = json.loads(self.blank(
|
||||
provider="zai", baseUrl="https://api.z.ai/api/anthropic",
|
||||
auth={"mode": "vault", "keyRef": "zai"}, contextTokens=1000000))
|
||||
want["extraEnv"] = {"API_TIMEOUT_MS": "3000000", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"}
|
||||
self.assertEqual(call(cm.cmd_scaffold, ["zai"]), json.dumps(want, indent=2) + "\n")
|
||||
|
||||
def test_lmstudio(self):
|
||||
want = json.loads(self.blank(
|
||||
provider="lmstudio", baseUrl="http://127.0.0.1:1234",
|
||||
auth={"mode": "literal", "token": "lmstudio"}, contextTokens=262144))
|
||||
want["extraEnv"] = {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"}
|
||||
self.assertEqual(call(cm.cmd_scaffold, ["lmstudio"]), json.dumps(want, indent=2) + "\n")
|
||||
|
||||
def test_new_providers(self):
|
||||
o = json.loads(call(cm.cmd_scaffold, ["ollama"]))
|
||||
self.assertEqual((o["baseUrl"], o["auth"], o["contextTokens"]),
|
||||
("http://127.0.0.1:11434", {"mode": "literal", "token": "ollama"}, 65536))
|
||||
c = json.loads(call(cm.cmd_scaffold, ["custom"]))
|
||||
self.assertEqual((c["baseUrl"], c["auth"]), ("", {"mode": "vault", "keyRef": "custom"}))
|
||||
|
||||
def test_unknown(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_scaffold, ["nope"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalogues
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Parsers(unittest.TestCase):
|
||||
def test_openrouter(self):
|
||||
src = {"data": [{"id": "b/x", "context_length": 1000, "pricing": {"prompt": "0.000001", "completion": "0.000002"}},
|
||||
{"id": "a/y", "context_length": 5, "pricing": {}}]}
|
||||
self.assertEqual(call(cm.cmd_or_models, [], json.dumps(src)).splitlines(),
|
||||
["a/y\t5\t\t", "b/x\t1000\t1.0\t2.0"])
|
||||
|
||||
def test_lmstudio(self):
|
||||
src = {"data": [{"id": "m", "state": "loaded", "max_context_length": 8192}]}
|
||||
self.assertEqual(call(cm.cmd_lms_models, [], json.dumps(src)), "m\tloaded\t8192\n")
|
||||
|
||||
def test_ollama_placeholders(self):
|
||||
# "-" rather than empty, so bash's tab-IFS read cannot merge columns.
|
||||
src = {"models": [{"name": "glm:cloud", "details": {}},
|
||||
{"name": "q:latest", "details": {"parameter_size": "7B", "quantization_level": "Q4"}}]}
|
||||
self.assertEqual(call(cm.cmd_ollama_models, [], json.dumps(src)).splitlines(),
|
||||
["glm:cloud\t-\t-\t-", "q:latest\t7B\tQ4\t-"])
|
||||
|
||||
def test_openai_both_shapes(self):
|
||||
self.assertEqual(call(cm.cmd_openai_models, [], json.dumps({"data": [{"id": "b"}, {"id": "a"}]})), "a\nb\n")
|
||||
self.assertEqual(call(cm.cmd_openai_models, [], json.dumps([{"id": "x"}])), "x\n")
|
||||
|
||||
|
||||
class Cache(TempDir):
|
||||
def cache(self, provider, ok, base, tsv):
|
||||
call(cm.cmd_cache_models, [self.path("c.json"), provider, "1" if ok else "0", base], tsv)
|
||||
with open(self.path("c.json")) as fh:
|
||||
return json.load(fh)["providers"][provider]
|
||||
|
||||
def test_per_server_and_failure(self):
|
||||
node = self.cache("ollama", True, "http://a:1/", "q:latest\t7B\tQ4\t-\n")
|
||||
self.assertEqual((node["baseUrl"], node["models"]), ("http://a:1", [{"id": "q:latest", "note": "7B Q4"}]))
|
||||
node = self.cache("ollama", False, "http://a:1", "")
|
||||
self.assertEqual((node["ok"], len(node["models"])), (False, 1)) # a stale list beats none
|
||||
node = self.cache("ollama", False, "http://b:2", "")
|
||||
self.assertEqual((node["baseUrl"], node["models"]), ("http://b:2", [])) # another server's list is wrong
|
||||
|
||||
def test_openrouter_prices_and_no_secrets(self):
|
||||
node = self.cache("openrouter", True, "", "a/y\t5\t0.1\t0.2\n")
|
||||
self.assertEqual(node["models"], [{"id": "a/y", "contextTokens": 5, "priceIn": 0.1, "priceOut": 0.2}])
|
||||
self.assertNotIn("baseUrl", node)
|
||||
with open(self.path("c.json")) as fh:
|
||||
text = fh.read()
|
||||
for word in ("keyRef", "token", "auth"):
|
||||
self.assertNotIn('"%s"' % word, text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session transcripts: scan, dismiss, repair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GOOD = {"type": "assistant", "message": {"id": "msg_1", "model": "claude-opus-5", "content": [{"type": "text", "text": "hi"}]}}
|
||||
FOREIGN = {"type": "assistant", "message": {"id": "gen-1-a", "model": "deepseek/x", "content": [{"type": "text", "text": "yo"}]}}
|
||||
|
||||
|
||||
class Sessions(TempDir):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.projects = self.path("projects")
|
||||
os.makedirs(os.path.join(self.projects, "-p"))
|
||||
self.ignored = self.path("ignored.json")
|
||||
|
||||
def transcript(self, sid, lines, age_days=0):
|
||||
p = os.path.join(self.projects, "-p", sid + ".jsonl")
|
||||
with open(p, "w") as fh:
|
||||
fh.write("\n".join(json.dumps(x) for x in lines) + "\n")
|
||||
t = time.time() - age_days * 86400
|
||||
os.utime(p, (t, t))
|
||||
return p
|
||||
|
||||
def scan(self, max_age=""):
|
||||
return call_json(cm.cmd_scan_sessions, [self.projects, max_age, self.ignored])
|
||||
|
||||
def test_scan_routes_by_reason(self):
|
||||
self.transcript("new", [GOOD, FOREIGN])
|
||||
self.transcript("old", [GOOD, FOREIGN], age_days=10)
|
||||
self.transcript("healthy", [GOOD])
|
||||
self.transcript("native", [FOREIGN]) # ran entirely on a gateway: not damage
|
||||
d = self.scan()
|
||||
self.assertEqual([b["sessionId"] for b in d["broken"]], ["new"])
|
||||
self.assertEqual([(i["sessionId"], i["reason"]) for i in d["ignored"]], [("old", "stale")])
|
||||
self.assertEqual(self.scan("0")["count"], 2)
|
||||
|
||||
def test_dismiss_list_prune(self):
|
||||
self.transcript("new", [GOOD, FOREIGN])
|
||||
gone = self.transcript("gone", [GOOD, FOREIGN])
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "new"])
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "gone"])
|
||||
self.assertEqual([(i["sessionId"], i["reason"]) for i in self.scan()["ignored"]],
|
||||
[("gone", "dismissed"), ("new", "dismissed")])
|
||||
rows = call_json(cm.cmd_ignore_session, [self.ignored, self.projects, "list"])["sessions"]
|
||||
self.assertTrue(all(r["broken"] and r["exists"] for r in rows))
|
||||
os.remove(gone)
|
||||
out = call_json(cm.cmd_ignore_session, [self.ignored, self.projects, "remove", "new"])
|
||||
self.assertEqual((out["changed"], out["pruned"], out["count"]), (True, 1, 0))
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "../x"])
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "nosuch"])
|
||||
|
||||
def test_repair_truncates_and_hands_back(self):
|
||||
p = self.transcript("fix", [GOOD, FOREIGN])
|
||||
v = call_json(cm.cmd_repair_session, [p, "--apply"])
|
||||
self.assertTrue(v["applied"] and v["reinjected"])
|
||||
self.assertTrue(os.path.exists(v["backup"]) and os.path.exists(v["recovered"]))
|
||||
with open(p) as fh:
|
||||
lines = [json.loads(x) for x in fh.read().splitlines()]
|
||||
self.assertEqual(lines[0]["message"]["id"], "msg_1")
|
||||
self.assertTrue(lines[1]["isMeta"])
|
||||
self.assertIn("<recovered-transcript>", lines[1]["message"]["content"])
|
||||
self.assertTrue(call_json(cm.cmd_repair_session, [p])["healthy"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presets: rename, defaults, auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Presets(TempDir):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.presets = self.path("presets")
|
||||
shutil.copytree(os.path.join(REPO, "presets"), self.presets)
|
||||
self.state = self.write_json("state.json", {"mode": "openrouter", "preset": "default", "writtenEnvKeys": []})
|
||||
self.defaults = self.write_json("defaults.json", {"openrouter": "default"})
|
||||
|
||||
def load(self, p):
|
||||
with open(p) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
def test_rename_repoints_state_and_default(self):
|
||||
out = call_json(cm.cmd_preset_rename, [self.presets, "default", "daily", self.state, self.defaults])
|
||||
self.assertEqual(out, {"renamed": True, "active": True, "default": True})
|
||||
self.assertEqual(self.load(self.state)["preset"], "daily")
|
||||
self.assertEqual(self.load(self.defaults)["openrouter"], "daily")
|
||||
self.assertFalse(os.path.exists(os.path.join(self.presets, "default.json")))
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_preset_rename, [self.presets, "daily", "zai", self.state])
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_preset_rename, [self.presets, "nosuch", "x", self.state])
|
||||
|
||||
def test_set_default_and_clear(self):
|
||||
call(cm.cmd_set_default, [self.defaults, "zai", "zai"])
|
||||
call(cm.cmd_set_default, [self.defaults, "openrouter", ""])
|
||||
self.assertEqual(self.load(self.defaults), {"zai": "zai"})
|
||||
|
||||
def test_auth_of(self):
|
||||
self.assertEqual(call(cm.cmd_auth_of, [os.path.join(self.presets, "zai.json")]), "vault\tzai\n")
|
||||
self.assertEqual(call(cm.cmd_auth_of, [os.path.join(self.presets, "ollama.json")]), "literal\topenrouter\n")
|
||||
with self.assertRaises(SystemExit) as e:
|
||||
call(cm.cmd_auth_of, [os.path.join(self.presets, "nosuch.json")])
|
||||
self.assertEqual(e.exception.code, 1)
|
||||
|
||||
def test_set_auth_uses_the_providers_names(self):
|
||||
p = os.path.join(self.presets, "ollama.json")
|
||||
call(cm.cmd_set_auth, [p, "key"])
|
||||
self.assertEqual(self.load(p)["auth"], {"mode": "vault", "keyRef": "ollama"})
|
||||
call(cm.cmd_set_auth, [p, "none"])
|
||||
self.assertEqual(self.load(p)["auth"], {"mode": "literal", "token": "ollama"})
|
||||
|
||||
|
||||
class Health(Presets):
|
||||
def health(self, mode="anthropic", preset=""):
|
||||
call(cm.cmd_health, [self.tmp, "/nonexistent", mode, preset, "", "file", "9.9.9"])
|
||||
return self.load(self.path("health.json"))
|
||||
|
||||
def test_effective_defaults(self):
|
||||
self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "default")
|
||||
shutil.copy(os.path.join(self.presets, "default.json"), os.path.join(self.presets, "cheap.json"))
|
||||
self.write_json("defaults.json", {"openrouter": "cheap"})
|
||||
h = self.health()
|
||||
self.assertEqual((h["defaultPresetFor"]["openrouter"], h["defaultPresetChosen"]), ("cheap", {"openrouter": "cheap"}))
|
||||
os.remove(os.path.join(self.presets, "cheap.json")) # a vanished choice falls back
|
||||
self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "default")
|
||||
os.remove(os.path.join(self.presets, "default.json")) # and with the built-in gone, the first by name
|
||||
shutil.copy(os.path.join(self.presets, "zai.json"), os.path.join(self.presets, "alpha.json"))
|
||||
alpha = self.load(os.path.join(self.presets, "alpha.json"))
|
||||
alpha["provider"] = "openrouter"
|
||||
self.write_json("presets/alpha.json", alpha)
|
||||
self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "alpha")
|
||||
|
||||
def test_publishes_providers(self):
|
||||
h = self.health()
|
||||
self.assertEqual(h["version"], "9.9.9")
|
||||
byid = {p["id"]: p for p in h["providers"]}
|
||||
self.assertEqual(list(byid), ["openrouter", "zai", "lmstudio", "ollama", "custom"])
|
||||
self.assertTrue(byid["ollama"]["serverEditable"] and byid["ollama"]["perServerCatalogue"])
|
||||
self.assertFalse(byid["zai"]["serverEditable"])
|
||||
self.assertEqual(byid["custom"]["defaultBaseUrl"], "")
|
||||
self.assertTrue(byid["openrouter"]["logo"])
|
||||
|
||||
|
||||
class OllamaCtx(unittest.TestCase):
|
||||
def test_show(self):
|
||||
src = {"model_info": {"general.architecture": "q", "q.context_length": 262144}}
|
||||
self.assertEqual(call(cm.cmd_ollama_ctx, ["show"], json.dumps(src)), "262144\n")
|
||||
|
||||
def test_ps_matches_latest(self):
|
||||
src = {"models": [{"name": "qwen3-coder:latest", "model": "qwen3-coder:latest", "context_length": 4096}]}
|
||||
self.assertEqual(call(cm.cmd_ollama_ctx, ["ps", "qwen3-coder"], json.dumps(src)), "4096\n")
|
||||
self.assertEqual(call(cm.cmd_ollama_ctx, ["ps", "other"], json.dumps(src)), "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply - the write into settings.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Apply(TempDir):
|
||||
def preset(self, **over):
|
||||
base = {"provider": "openrouter", "baseUrl": "https://gw", "auth": {"mode": "vault", "keyRef": "openrouter"},
|
||||
"models": {"opus": "x/opus", "sonnet": "x/sonnet"}, "contextTokens": 1000}
|
||||
base.update(over)
|
||||
return self.write_json("p.json", base)
|
||||
|
||||
def apply(self, mode, preset="", helper="/opt/key helper/h.sh"):
|
||||
return call(cm.cmd_apply, [self.path("settings.json"), self.path("state.json"), mode, preset, helper])
|
||||
|
||||
def settings(self):
|
||||
with open(self.path("settings.json")) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
def test_writes_env_and_quotes_the_helper(self):
|
||||
self.apply("openrouter", self.preset())
|
||||
s = self.settings()
|
||||
self.assertEqual(s["env"]["ANTHROPIC_BASE_URL"], "https://gw")
|
||||
self.assertEqual(s["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], "x/opus")
|
||||
self.assertEqual(s["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], "1000")
|
||||
self.assertEqual(s["env"]["ANTHROPIC_API_KEY"], "")
|
||||
self.assertEqual(s["apiKeyHelper"], "'/opt/key helper/h.sh'")
|
||||
|
||||
def test_cost_guard(self):
|
||||
with self.assertRaises(SystemExit) as e:
|
||||
self.apply("openrouter", self.preset(models={"opus": "anthropic/claude-opus-5"}))
|
||||
self.assertIn("refusing to switch", str(e.exception))
|
||||
self.apply("openrouter", self.preset(models={"opus": "anthropic/claude-opus-5"}, allowAnthropicModels=True))
|
||||
|
||||
def test_extra_env_is_removed_on_the_way_out(self):
|
||||
self.apply("openrouter", self.preset(extraEnv={"SOME_CUSTOM_KEY": "1"}))
|
||||
self.assertIn("SOME_CUSTOM_KEY", self.settings()["env"])
|
||||
self.apply("anthropic")
|
||||
s = self.settings()
|
||||
self.assertNotIn("env", s)
|
||||
self.assertNotIn("apiKeyHelper", s)
|
||||
|
||||
def test_literal_token_and_provider_check(self):
|
||||
self.apply("ollama", self.preset(provider="ollama", auth={"mode": "literal", "token": "ollama"}))
|
||||
self.assertEqual(self.settings()["env"]["ANTHROPIC_AUTH_TOKEN"], "ollama")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.apply("zai", self.preset())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks that need nothing but the tree: script syntax, JSON validity, the
|
||||
# version recorded in both places, and providers.json against the kinds of
|
||||
# behaviour the code knows. qmllint and shellcheck run when installed and are
|
||||
# skipped - out loud - when not.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
fails=0
|
||||
err="$(mktemp)"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$err" "$tmp"' EXIT
|
||||
|
||||
check() {
|
||||
local what="$1"; shift
|
||||
if "$@" >"$err" 2>&1; then
|
||||
printf ' ok %s\n' "$what"
|
||||
else
|
||||
printf ' FAIL %s\n' "$what"
|
||||
sed 's/^/ | /' "$err" | head -n 20
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
bash_syntax() {
|
||||
local f
|
||||
for f in linux/claude-mode linux/*.sh linux/lib/*.sh omarchy/install.sh scripts/*.sh \
|
||||
tests/*.sh tests/cli/*.sh tests/windows/*.sh; do
|
||||
[ -e "$f" ] || continue
|
||||
bash -n "$f" || { echo "$f"; return 1; }
|
||||
done
|
||||
}
|
||||
check 'shell scripts parse (bash -n)' bash_syntax
|
||||
|
||||
check 'python compiles' env PYTHONPYCACHEPREFIX="$tmp" python3 -m py_compile \
|
||||
linux/cm-json.py tests/fake_server.py tests/python/test_cm_json.py
|
||||
|
||||
check 'JSON files parse' python3 -c '
|
||||
import json, sys, glob
|
||||
for f in ["providers.json", "omarchy/smoido.claude-mode/manifest.json"] + glob.glob("presets/*.json"):
|
||||
try:
|
||||
json.load(open(f, encoding="utf-8"))
|
||||
except ValueError as e:
|
||||
sys.exit("%s: %s" % (f, e))'
|
||||
|
||||
check 'VERSION matches the widget manifest' python3 -c '
|
||||
import json
|
||||
v = open("VERSION").read().strip()
|
||||
m = json.load(open("omarchy/smoido.claude-mode/manifest.json"))["version"]
|
||||
assert v == m, "VERSION is %s but manifest.json says %s - use scripts/bump-version.sh" % (v, m)'
|
||||
|
||||
check 'CHANGELOG has an entry for VERSION' python3 -c '
|
||||
v = open("VERSION").read().strip()
|
||||
assert ("## %s " % v) in open("CHANGELOG.md", encoding="utf-8").read(), "no CHANGELOG.md entry for " + v'
|
||||
|
||||
# The values each field may take are the ones the code has a branch for. A new
|
||||
# kind of behaviour needs code in cm-json.py, the bash CLI and claude-mode.ps1
|
||||
# before it can appear here - this is where that is enforced.
|
||||
check 'providers.json is consistent with the code and the presets' python3 - <<'PY'
|
||||
import json, os, sys
|
||||
KINDS = {"openrouter", "lmstudio", "ollama", "openai", "static"}
|
||||
PROBES = {"always", "lenient", "local"}
|
||||
KEYS = {"required", "optional"}
|
||||
MODELS = {"per-tier", "one-for-all"}
|
||||
CHECKS = {"openrouter-key", "guardrail", "message-check", "catalogue-models",
|
||||
"ollama-context", "lmstudio-templates"}
|
||||
COLORS = {"cyan", "green", "yellow", "magenta", "white", "gray", "dkcyan"}
|
||||
COMMANDS = {"menu", "status", "anthropic", "presets", "preset", "set-key", "models",
|
||||
"doctor", "health", "preflight", "setup", "sessions", "repair-session",
|
||||
"repair", "help"}
|
||||
errors = []
|
||||
def bad(pid, msg): errors.append("%s: %s" % (pid, msg))
|
||||
|
||||
doc = json.load(open("providers.json", encoding="utf-8"))
|
||||
seen = {}
|
||||
for p in doc["providers"]:
|
||||
pid = p.get("id", "?")
|
||||
for name in [pid] + list(p.get("aliases") or []):
|
||||
if name in COMMANDS:
|
||||
bad(pid, "'%s' would shadow the '%s' command" % (name, name))
|
||||
if name in seen:
|
||||
bad(pid, "'%s' is already used by %s" % (name, seen[name]))
|
||||
seen[name] = pid
|
||||
for field in ("title", "label", "blurb", "defaultPreset", "preset", "catalogue", "setup", "logo"):
|
||||
if not p.get(field):
|
||||
bad(pid, "missing " + field)
|
||||
cat, srv, setup = p.get("catalogue") or {}, p.get("server") or {}, p.get("setup") or {}
|
||||
if cat.get("kind") not in KINDS:
|
||||
bad(pid, "catalogue.kind %r is not one of %s" % (cat.get("kind"), sorted(KINDS)))
|
||||
if cat.get("kind") == "static" and not cat.get("static"):
|
||||
bad(pid, "a static catalogue needs a static list")
|
||||
if srv.get("probe", "local") not in PROBES:
|
||||
bad(pid, "server.probe %r is not one of %s" % (srv.get("probe"), sorted(PROBES)))
|
||||
if srv.get("probe") in ("always", "lenient") and not srv.get("paths"):
|
||||
bad(pid, "a probed server needs server.paths")
|
||||
if setup.get("key", "required") not in KEYS:
|
||||
bad(pid, "setup.key %r is not one of %s" % (setup.get("key"), sorted(KEYS)))
|
||||
if setup.get("models", "per-tier") not in MODELS:
|
||||
bad(pid, "setup.models %r is not one of %s" % (setup.get("models"), sorted(MODELS)))
|
||||
for c in p.get("doctor") or []:
|
||||
if c not in CHECKS:
|
||||
bad(pid, "unknown doctor check %r" % c)
|
||||
if p.get("color", "gray") not in COLORS:
|
||||
bad(pid, "color %r is not one of %s" % (p.get("color"), sorted(COLORS)))
|
||||
auth = (p.get("preset") or {}).get("auth") or {}
|
||||
if auth.get("mode") not in ("vault", "literal"):
|
||||
bad(pid, "preset.auth.mode must be vault or literal")
|
||||
preset = os.path.join("presets", "%s.json" % p.get("defaultPreset"))
|
||||
if not os.path.exists(preset):
|
||||
bad(pid, "default preset %s does not exist" % preset)
|
||||
elif json.load(open(preset, encoding="utf-8")).get("provider") != pid:
|
||||
bad(pid, "default preset %s belongs to another provider" % preset)
|
||||
for f in sorted(os.listdir("presets")):
|
||||
pp = json.load(open(os.path.join("presets", f), encoding="utf-8"))
|
||||
if pp.get("provider") not in {p["id"] for p in doc["providers"]}:
|
||||
errors.append("presets/%s: provider %r is not in providers.json" % (f, pp.get("provider")))
|
||||
if errors:
|
||||
sys.exit("\n".join(errors))
|
||||
PY
|
||||
|
||||
QMLLINT="$(command -v qmllint || true)"
|
||||
[ -z "$QMLLINT" ] && [ -x /usr/lib/qt6/bin/qmllint ] && QMLLINT=/usr/lib/qt6/bin/qmllint
|
||||
if [ -n "$QMLLINT" ]; then
|
||||
# The shell's own modules (qs.*, Quickshell) do not resolve outside it, so
|
||||
# only real syntax errors count; unresolved-type warnings are expected.
|
||||
qml_syntax() {
|
||||
local f out
|
||||
for f in omarchy/smoido.claude-mode/*.qml; do
|
||||
out="$("$QMLLINT" "$f" 2>&1 | grep -iE 'error|expected token|syntax|unexpected|duplicate' || true)"
|
||||
[ -z "$out" ] || { echo "$f"; echo "$out"; return 1; }
|
||||
done
|
||||
}
|
||||
check 'QML has no syntax errors (qmllint)' qml_syntax
|
||||
else
|
||||
printf ' skip QML syntax (qmllint not installed)\n'
|
||||
fi
|
||||
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
check 'shellcheck (errors only)' shellcheck -S error -s bash \
|
||||
linux/claude-mode linux/*.sh omarchy/install.sh scripts/*.sh tests/*.sh tests/cli/*.sh
|
||||
else
|
||||
printf ' skip shellcheck (not installed)\n'
|
||||
fi
|
||||
|
||||
[ "$fails" -eq 0 ]
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs tests/windows/run.ps1 on a Windows host over SSH (default: winbox):
|
||||
# copies the payload to the host's %TEMP%, runs it there, and removes it again.
|
||||
# Needs key-based SSH to the host and python on the host (for the fake servers).
|
||||
# The suite itself runs in a USERPROFILE sandbox; see run.ps1.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
host="${1:-winbox}"
|
||||
name='cm-test-payload'
|
||||
ssh_() { ssh -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=10 "$host" "$@"; }
|
||||
|
||||
parent="$(mktemp -d)"
|
||||
trap 'rm -rf "$parent"' EXIT
|
||||
stage="$parent/$name"
|
||||
mkdir -p "$stage/presets" "$stage/bin"
|
||||
cp claude-mode.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/"
|
||||
cp presets/*.json "$stage/presets/"
|
||||
cp bin/* "$stage/bin/"
|
||||
|
||||
rtemp="$(ssh_ 'Write-Output $env:TEMP' | tr -d '\r')" || { echo "cannot reach $host" >&2; exit 1; }
|
||||
[ -n "$rtemp" ] || { echo "no %TEMP% on $host" >&2; exit 1; }
|
||||
ssh_ "Remove-Item -Recurse -Force '$rtemp\\$name' -ErrorAction SilentlyContinue" || true
|
||||
scp -q -r -o BatchMode=yes -o LogLevel=ERROR "$stage" "$host:${rtemp//\\//}/" || { echo "copy to $host failed" >&2; exit 1; }
|
||||
|
||||
ssh_ "powershell -NoProfile -ExecutionPolicy Bypass -File '$rtemp\\$name\\run.ps1'"
|
||||
rc=$?
|
||||
ssh_ "Remove-Item -Recurse -Force '$rtemp\\$name' -ErrorAction SilentlyContinue" || true
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,127 @@
|
||||
# Windows suite for claude-mode.ps1. Run on a Windows host by
|
||||
# tests/windows/run-remote.sh, from a copy of the payload in that host's %TEMP%.
|
||||
#
|
||||
# Everything happens in a sandbox: USERPROFILE is pointed at a temp folder for
|
||||
# this process and its children, and the script derives every path from it, so
|
||||
# the host's real ~\.claude-mode, vault and Claude settings are never read or
|
||||
# written. The fake provider servers run on the host's own loopback.
|
||||
# ASCII only: Windows PowerShell 5.1 reads a .ps1 without a BOM as ANSI.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$src = $PSScriptRoot
|
||||
$sb = Join-Path $env:TEMP ('cm-test-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$root = Join-Path $sb '.claude-mode'
|
||||
$real = $env:USERPROFILE
|
||||
$script:pass = 0
|
||||
$script:fail = 0
|
||||
|
||||
function Pass { $script:pass++ }
|
||||
function Fail([string] $what, $out) {
|
||||
$script:fail++
|
||||
Write-Host " FAIL windows: $what"
|
||||
if ($out) { @($out) | Select-Object -Last 8 | ForEach-Object { Write-Host " | $_" } }
|
||||
}
|
||||
function Expect-Match($out, [string] $pattern, [string] $what) {
|
||||
if ((@($out) -join "`n") -match $pattern) { Pass } else { Fail $what $out }
|
||||
}
|
||||
function Expect-Eq($got, $want, [string] $what) {
|
||||
if ("$got" -eq "$want") { Pass } else { Fail "$what (got '$got', wanted '$want')" $null }
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$sb\.claude" | Out-Null
|
||||
Copy-Item "$src\claude-mode.ps1", "$src\providers.json", "$src\VERSION" $root
|
||||
Copy-Item "$src\presets\*.json" "$root\presets"
|
||||
Copy-Item "$src\bin\*" "$root\bin"
|
||||
$cm = Join-Path $root 'claude-mode.ps1'
|
||||
|
||||
$tokens = $null; $errors = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile($cm, [ref]$tokens, [ref]$errors)
|
||||
Expect-Eq $errors.Count 0 'claude-mode.ps1 parses'
|
||||
foreach ($e in $errors) { Write-Host " | line $($e.Extent.StartLineNumber): $($e.Message)" }
|
||||
|
||||
$portsFile = Join-Path $sb 'ports.json'
|
||||
$fake = Start-Process python -ArgumentList ('"' + (Join-Path $src 'fake_server.py') + '"') `
|
||||
-NoNewWindow -RedirectStandardOutput $portsFile -PassThru
|
||||
for ($i = 0; $i -lt 50; $i++) {
|
||||
if ((Test-Path $portsFile) -and (Get-Item $portsFile).Length -gt 0) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
$ports = Get-Content $portsFile -TotalCount 1 | ConvertFrom-Json
|
||||
$ollama = "http://127.0.0.1:$($ports.ollama)"
|
||||
$keyed = "http://127.0.0.1:$($ports.keyed)"
|
||||
|
||||
$env:USERPROFILE = $sb
|
||||
$script:rc = 0
|
||||
function CM {
|
||||
param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $a)
|
||||
$o = & powershell -NoProfile -ExecutionPolicy Bypass -File $cm @a 2>&1 | ForEach-Object { "$_" }
|
||||
$script:rc = $LASTEXITCODE
|
||||
return $o
|
||||
}
|
||||
function Set-PresetJson([string] $name, [scriptblock] $edit) {
|
||||
$p = Join-Path $root "presets\$name.json"
|
||||
$j = Get-Content $p -Raw | ConvertFrom-Json
|
||||
& $edit $j
|
||||
$j | ConvertTo-Json -Depth 6 | Set-Content $p -Encoding UTF8
|
||||
}
|
||||
|
||||
$o = CM help
|
||||
foreach ($id in 'openrouter', 'zai', 'lmstudio', 'ollama', 'custom') {
|
||||
Expect-Match $o "claude-mode $id \[preset\]" "usage lists $id"
|
||||
}
|
||||
|
||||
$o = CM custom
|
||||
Expect-Eq $rc 1 'custom without an address is refused'
|
||||
Expect-Match $o 'has no server address' 'and says why'
|
||||
Copy-Item "$root\presets\custom.json" "$root\presets\blank.json"
|
||||
Set-PresetJson 'blank' { param($j) $j.baseUrl = 'https://llm.example.com' }
|
||||
$o = CM custom blank
|
||||
Expect-Eq $rc 1 'a preset with no models is refused'
|
||||
Expect-Match $o 'has no models set' 'and says why'
|
||||
|
||||
Set-PresetJson 'ollama' { param($j) $j.baseUrl = $ollama }
|
||||
$o = CM ollama
|
||||
Expect-Eq $rc 0 'switch to ollama'
|
||||
$envBlock = (Get-Content "$sb\.claude\settings.json" -Raw | ConvertFrom-Json).env
|
||||
Expect-Eq $envBlock.ANTHROPIC_BASE_URL $ollama 'base url written'
|
||||
Expect-Eq $envBlock.ANTHROPIC_AUTH_TOKEN 'ollama' 'placeholder token written'
|
||||
Expect-Eq $envBlock.CLAUDE_CODE_MAX_CONTEXT_TOKENS '65536' 'context declared'
|
||||
|
||||
$o = CM models
|
||||
Expect-Match $o 'qwen3-coder:latest' 'ollama models listed'
|
||||
$o = CM doctor
|
||||
Expect-Match $o 'Ollama reachable' 'doctor finds the server'
|
||||
Expect-Match $o 'opus\s+qwen3-coder\s+\[30\.5B Q4_K_M\]' 'a bare name matches its :latest tag'
|
||||
Expect-Match $o 'loaded with a 4096-token context' 'the short server context is caught'
|
||||
Expect-Match $o 'OLLAMA_CONTEXT_LENGTH=65536' 'and the fix is named'
|
||||
|
||||
Set-PresetJson 'custom' {
|
||||
param($j)
|
||||
$j.baseUrl = $keyed
|
||||
$j.auth = [pscustomobject]@{ mode = 'literal'; token = 'sk-real' }
|
||||
$j.models.opus = 'gw/model-a'
|
||||
}
|
||||
$o = CM custom
|
||||
Expect-Eq $rc 0 'switch to a custom endpoint'
|
||||
$o = CM models
|
||||
Expect-Match $o 'gw/model-b' 'a keyed endpoint lists its models'
|
||||
|
||||
$o = CM 'Z.AI'
|
||||
Expect-Match $o "no key stored for ref 'zai'" 'an alias resolves to its provider'
|
||||
$o = CM nope
|
||||
Expect-Eq $rc 1 'an unknown command fails'
|
||||
|
||||
$want = '{"provider":"zai","description":"new preset","baseUrl":"https://api.z.ai/api/anthropic","auth":{"mode":"vault","keyRef":"zai"},"models":{"opus":"","sonnet":"","haiku":"","fable":""},"subagentModel":"inherit","gatewayModelDiscovery":false,"contextTokens":1000000,"extraEnv":{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1"}}'
|
||||
$got = & powershell -NoProfile -ExecutionPolicy Bypass -Command ". '$cm' help *> `$null; New-PresetScaffold 'zai' | ConvertTo-Json -Depth 5 -Compress"
|
||||
Expect-Eq $got $want 'the zai scaffold is unchanged'
|
||||
|
||||
Stop-Process -Id $fake.Id -Force -ErrorAction SilentlyContinue
|
||||
$env:USERPROFILE = $real
|
||||
Start-Sleep -Milliseconds 300
|
||||
Remove-Item -Recurse -Force $sb -ErrorAction SilentlyContinue
|
||||
|
||||
$line = '{0,-22} {1,3} passed' -f 'windows', $script:pass
|
||||
if ($script:fail -gt 0) { $line += ", $($script:fail) FAILED" }
|
||||
Write-Host $line
|
||||
if ($script:fail -gt 0) { exit 1 }
|
||||
exit 0
|
||||
Reference in New Issue
Block a user