276 lines
12 KiB
YAML
276 lines
12 KiB
YAML
---
|
|
# Offline regression tests for the role's Jinja expressions, run against
|
|
# fixtures captured from a real `multica` CLI. No target host required:
|
|
#
|
|
# ansible-playbook -i localhost, tests/expressions.yml
|
|
#
|
|
# This does NOT replace the scratch-VM run described in the role README; it
|
|
# catches the class of bug where a filter chain silently returns the wrong
|
|
# type or quietly matches nothing.
|
|
- name: Expressions | parsing real multica CLI output
|
|
hosts: localhost
|
|
gather_facts: false
|
|
vars:
|
|
multica_server_url: "https://multica.wdw-labs.cloud"
|
|
multica_app_url: ""
|
|
multica_workspaces_root: "/srv/multica/workspaces"
|
|
multica_device_name: "rblundon--Mac16"
|
|
multica_runtime_name: ""
|
|
multica_max_concurrent_tasks: ""
|
|
multica_poll_interval: ""
|
|
multica_heartbeat_interval: ""
|
|
multica_agent_timeout: ""
|
|
multica_disable_auto_update: true
|
|
multica_version: "0.6.5"
|
|
multica_agent_clis: [claude, hermes]
|
|
multica_agent_cli_catalog:
|
|
claude: {provider: claude, method: npm}
|
|
hermes: {provider: hermes, method: script}
|
|
cursor: {provider: cursor, method: npm}
|
|
_multica_arch_map: {x86_64: amd64, aarch64: arm64}
|
|
|
|
tasks:
|
|
- name: Load fixtures
|
|
ansible.builtin.set_fact:
|
|
rt: "{{ lookup('file', '{{ playbook_dir }}/fixtures/runtime-list.json') }}"
|
|
ds: "{{ lookup('file', '{{ playbook_dir }}/fixtures/daemon-status.json') }}"
|
|
cs_lines: "{{ lookup('file', '{{ playbook_dir }}/fixtures/config-show.txt').splitlines() }}"
|
|
ver_out: "multica 0.6.5 (commit: 654b8ca95, built: 2026-10-21T09:41:38Z)"
|
|
|
|
# --- CLI version extraction (multica_cli.yml) ---------------------------
|
|
- name: Extract installed CLI version
|
|
ansible.builtin.set_fact:
|
|
found: >-
|
|
{{ ((([ver_out] | first | default('')).split() + ['', ''])[1]) }}
|
|
- name: Assert version extraction
|
|
ansible.builtin.assert:
|
|
that: [(found | trim) == multica_version]
|
|
success_msg: "CLI version extraction OK -> {{ found }}"
|
|
|
|
# --- config show parsing (config.yml) -----------------------------------
|
|
- name: Parse config show
|
|
ansible.builtin.set_fact:
|
|
current: >-
|
|
{{ dict(
|
|
cs_lines
|
|
| select('match', '^[a-z_]+:')
|
|
| map('regex_replace', '^([a-z_]+):\s*(.*?)\s*$', '\1|\2')
|
|
| map('split', '|')
|
|
) }}
|
|
- name: Build desired config
|
|
ansible.builtin.set_fact:
|
|
desired: >-
|
|
{{ {
|
|
'server_url': multica_server_url,
|
|
'app_url': multica_app_url,
|
|
'workspaces_root': multica_workspaces_root,
|
|
'device_name': multica_device_name,
|
|
'runtime_name': multica_runtime_name,
|
|
'max_concurrent_tasks': multica_max_concurrent_tasks,
|
|
'poll_interval': multica_poll_interval,
|
|
'heartbeat_interval': multica_heartbeat_interval,
|
|
'agent_timeout': multica_agent_timeout,
|
|
'disable_auto_update': ('true' if multica_disable_auto_update | bool else 'false'),
|
|
} | dict2items
|
|
| rejectattr('value', 'equalto', '')
|
|
| list | items2dict }}
|
|
- name: Show parse results
|
|
ansible.builtin.debug:
|
|
msg:
|
|
parsed_keys: "{{ current.keys() | list }}"
|
|
disable_auto_update_now: "{{ current['disable_auto_update'] | default('MISSING') }}"
|
|
server_url_now: "{{ current['server_url'] | default('MISSING') }}"
|
|
desired: "{{ desired }}"
|
|
would_change: >-
|
|
{{ desired | dict2items
|
|
| selectattr('key', 'in', current.keys() | list)
|
|
| rejectattr('value', 'equalto', 'IMPOSSIBLE')
|
|
| map(attribute='key') | list }}
|
|
- name: Assert config parsing found the expected keys
|
|
ansible.builtin.assert:
|
|
that:
|
|
- "'server_url' in current"
|
|
- "'disable_auto_update' in current"
|
|
- current['server_url'] == '(not set)'
|
|
- current['disable_auto_update'] == 'false'
|
|
- desired | length == 4
|
|
- "'app_url' not in desired"
|
|
success_msg: "config show parsing + desired-config filtering OK"
|
|
|
|
# --- runtime verification (verify.yml) ----------------------------------
|
|
- name: Compute online providers for this daemon
|
|
ansible.builtin.set_fact:
|
|
expected: "{{ multica_agent_clis | map('extract', multica_agent_cli_catalog, 'provider') | list }}"
|
|
online: >-
|
|
{{ rt | from_json
|
|
| selectattr('daemon_id', 'equalto', (ds | from_json).daemon_id)
|
|
| selectattr('status', 'equalto', 'online')
|
|
| map(attribute='provider') | list }}
|
|
- name: Assert every expected provider is online
|
|
ansible.builtin.assert:
|
|
that: [(expected | difference(online)) | length == 0]
|
|
success_msg: "runtime check OK: expected={{ expected }} online={{ online }}"
|
|
|
|
- name: Assert device_info naming check
|
|
ansible.builtin.assert:
|
|
that:
|
|
- rt | from_json
|
|
| selectattr('daemon_id', 'equalto', (ds | from_json).daemon_id)
|
|
| rejectattr('device_info', 'search', '^' ~ (multica_device_name | regex_escape))
|
|
| list | length == 0
|
|
success_msg: "device_info check OK for {{ multica_device_name }}"
|
|
|
|
- name: Assert daemon status fields
|
|
ansible.builtin.assert:
|
|
that:
|
|
- (ds | from_json).status == 'running'
|
|
- (ds | from_json).cli_version == multica_version
|
|
- ((ds | from_json).server_url | regex_replace('/$','')) == (multica_server_url | regex_replace('/$',''))
|
|
success_msg: "daemon status assertions OK"
|
|
|
|
# --- negative case: a provider that is NOT online ------------------------
|
|
- name: Assert a missing provider is detected
|
|
ansible.builtin.assert:
|
|
that:
|
|
- (['codex'] | difference(online)) | length == 1
|
|
success_msg: "missing-provider detection OK"
|
|
|
|
- name: Expressions | preflight fact-building
|
|
hosts: localhost
|
|
gather_facts: false
|
|
vars:
|
|
multica_install_dir: /usr/local/bin
|
|
multica_agent_cli_catalog:
|
|
claude: {provider: claude, method: npm}
|
|
hermes: {provider: hermes, method: script}
|
|
multica_release_download_base_url: "https://github.com/multica-ai/multica/releases/download"
|
|
multica_version: "0.6.5"
|
|
_multica_arch_map: {x86_64: amd64, aarch64: arm64}
|
|
tasks:
|
|
- name: Case - profile unset, npm CLI selected
|
|
ansible.builtin.set_fact:
|
|
multica_profile: ""
|
|
multica_agent_clis: [claude, hermes]
|
|
_multica_arch: "{{ _multica_arch_map['x86_64'] }}"
|
|
- name: Build facts
|
|
ansible.builtin.set_fact:
|
|
_multica_cmd: >-
|
|
{{ [multica_install_dir ~ '/multica']
|
|
+ (['--profile', multica_profile] if multica_profile | length > 0 else []) }}
|
|
_multica_node_required: >-
|
|
{{ multica_agent_clis
|
|
| map('extract', multica_agent_cli_catalog, 'method')
|
|
| select('equalto', 'npm') | list | length > 0 }}
|
|
_multica_release_url: >-
|
|
{{ multica_release_download_base_url }}/v{{ multica_version }}/multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz
|
|
- name: Assert types and values
|
|
ansible.builtin.assert:
|
|
that:
|
|
- _multica_cmd is sequence
|
|
- _multica_cmd is not string
|
|
- _multica_cmd == ['/usr/local/bin/multica']
|
|
- (_multica_cmd + ['daemon', 'status']) == ['/usr/local/bin/multica', 'daemon', 'status']
|
|
- _multica_node_required | bool
|
|
- _multica_release_url == 'https://github.com/multica-ai/multica/releases/download/v0.6.5/multica-cli-0.6.5-linux-amd64.tar.gz'
|
|
success_msg: "argv prefix + node detection + URL OK: {{ _multica_cmd }}"
|
|
|
|
- name: Case - profile set, script-only CLI selected
|
|
ansible.builtin.set_fact:
|
|
multica_profile: "haunted"
|
|
multica_agent_clis: [hermes]
|
|
- name: Rebuild facts
|
|
ansible.builtin.set_fact:
|
|
_multica_cmd: >-
|
|
{{ [multica_install_dir ~ '/multica']
|
|
+ (['--profile', multica_profile] if multica_profile | length > 0 else []) }}
|
|
_multica_node_required: >-
|
|
{{ multica_agent_clis
|
|
| map('extract', multica_agent_cli_catalog, 'method')
|
|
| select('equalto', 'npm') | list | length > 0 }}
|
|
- name: Assert profile + no-node case
|
|
ansible.builtin.assert:
|
|
that:
|
|
- _multica_cmd == ['/usr/local/bin/multica', '--profile', 'haunted']
|
|
- not (_multica_node_required | bool)
|
|
success_msg: "profile prefix + node-not-required OK: {{ _multica_cmd }}"
|
|
|
|
- name: Assert PAT prefix validation accepts both token kinds and rejects junk
|
|
ansible.builtin.assert:
|
|
that:
|
|
- "'mul_abc123' is match('^(mul_|mcn_)')"
|
|
- "'mcn_abc123' is match('^(mul_|mcn_)')"
|
|
- "not ('ghp_abc123' is match('^(mul_|mcn_)'))"
|
|
- "not ('' is match('^(mul_|mcn_)'))"
|
|
success_msg: "PAT validation OK"
|
|
|
|
- name: Assert unknown-agent detection
|
|
ansible.builtin.assert:
|
|
that:
|
|
- "['claude', 'bogus'] | difference(multica_agent_cli_catalog.keys() | list) == ['bogus']"
|
|
- "['claude', 'hermes'] | difference(multica_agent_cli_catalog.keys() | list) | length == 0"
|
|
success_msg: "catalog validation OK"
|
|
|
|
- name: Assert workspaces-root-under-mount logic
|
|
vars:
|
|
root: "/mnt/utilidor/multica/workspaces"
|
|
mnt: "/mnt/utilidor"
|
|
bad_root: "/mnt/utilidor-other/workspaces"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- root == mnt or root.startswith(mnt.rstrip('/') ~ '/')
|
|
- not (bad_root == mnt or bad_root.startswith(mnt.rstrip('/') ~ '/'))
|
|
success_msg: "mount containment check OK (rejects /mnt/utilidor-other)"
|
|
|
|
- name: Expressions | pinned agent-CLI version guard
|
|
hosts: localhost
|
|
gather_facts: false
|
|
vars:
|
|
# Mirrors the expression in tasks/agent_cli/npm.yml.
|
|
guard: >-
|
|
{{ rc != 0
|
|
or (pin | default('') | length > 0
|
|
and not ((out | default('')) is search(
|
|
'(^|[^0-9.])' ~ (pin | regex_escape) ~ '([^0-9.]|$)'))) }}
|
|
tasks:
|
|
- name: Pinned version already installed - must NOT reinstall
|
|
ansible.builtin.assert:
|
|
that: [not (guard | bool)]
|
|
success_msg: "2.1.304 pin vs '2.1.304 (Claude Code)' -> no install"
|
|
vars: {rc: 0, pin: "2.1.304", out: "2.1.304 (Claude Code)"}
|
|
|
|
- name: Different version installed - must reinstall
|
|
ansible.builtin.assert:
|
|
that: [guard | bool]
|
|
success_msg: "2.1.304 pin vs 2.1.306 installed -> install"
|
|
vars: {rc: 0, pin: "2.1.304", out: "2.1.306 (Claude Code)"}
|
|
|
|
- name: Longer version must not satisfy a shorter pin
|
|
ansible.builtin.assert:
|
|
that: [guard | bool]
|
|
success_msg: "2.1.304 pin vs 2.1.3045 installed -> install (no substring false-match)"
|
|
vars: {rc: 0, pin: "2.1.304", out: "2.1.3045 (Claude Code)"}
|
|
|
|
- name: Prefix of installed version must not satisfy the pin
|
|
ansible.builtin.assert:
|
|
that: [guard | bool]
|
|
success_msg: "2.1.30 pin vs 2.1.304 installed -> install"
|
|
vars: {rc: 0, pin: "2.1.30", out: "2.1.304 (Claude Code)"}
|
|
|
|
- name: Version embedded mid-string still matches (hermes-style output)
|
|
ansible.builtin.assert:
|
|
that: [not (guard | bool)]
|
|
success_msg: "0.21.5 pin vs 'Hermes Agent v0.21.5+2024.gb609c74' -> no install"
|
|
vars: {rc: 0, pin: "0.21.5", out: "Hermes Agent v0.21.5+2024.gb609c74 (2026.9.24)"}
|
|
|
|
- name: Binary absent - must install regardless of pin
|
|
ansible.builtin.assert:
|
|
that: [guard | bool]
|
|
success_msg: "rc!=0 -> install"
|
|
vars: {rc: 1, pin: "2.1.304", out: ""}
|
|
|
|
- name: Unpinned tracks latest - never reinstalls once present
|
|
ansible.builtin.assert:
|
|
that: [not (guard | bool)]
|
|
success_msg: "empty pin + present -> no install"
|
|
vars: {rc: 0, pin: "", out: "2.1.306 (Claude Code)"}
|