mirror of
https://github.com/spantaleev/matrix-docker-ansible-deploy.git
synced 2026-09-20 23:00:12 +00:00
Select Molecule scenarios by shared dependency usage
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Select Molecule scenarios affected by a Git comparison, using only the stdlib.
|
||||
|
||||
Shared dependencies use literal molecule-shared/... paths and image variable names.
|
||||
Follow these references transitively, in both revisions. This is deliberately not an
|
||||
Ansible interpreter: unfamiliar image-pin syntax or unresolved dependencies run all
|
||||
scenarios instead of risking an incomplete automerge gate.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_VARS = "molecule-shared/vars.yml"
|
||||
GLOBAL_FILES = {
|
||||
"molecule-shared/requirements.txt",
|
||||
"molecule-shared/requirements.yml",
|
||||
"molecule-shared/playbook-context.yml",
|
||||
".github/workflows/molecule.yml",
|
||||
"bin/molecule-select-roles.py",
|
||||
"bin/test-molecule-select-roles.py",
|
||||
}
|
||||
SCENARIO = re.compile(r"roles/custom/([^/]+)/molecule/default/molecule\.yml$")
|
||||
SHARED_PATH = re.compile(r"molecule-shared/[\w./-]+")
|
||||
IMAGE_NAME = re.compile(r"\bmolecule_shared_image_\w+\b")
|
||||
IMAGE_PIN = re.compile(r"(molecule_shared_image_\w+):\s*(['\"])([\w./:@+-]+)\2\s*(?:#.*)?$")
|
||||
|
||||
|
||||
class Uncertain(Exception):
|
||||
"""The change cannot safely be narrowed to particular scenarios."""
|
||||
|
||||
|
||||
class Git:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
|
||||
def run(self, *args, input=None):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=self.root, input=input, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, check=True,
|
||||
).stdout
|
||||
|
||||
def commit(self, ref):
|
||||
return self.run("rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}").decode().strip()
|
||||
|
||||
def snapshot(self, ref):
|
||||
entries = {}
|
||||
for entry in self.run("ls-tree", "-rz", ref, "--", "roles/custom", "molecule-shared").split(b"\0"):
|
||||
if not entry:
|
||||
continue
|
||||
metadata, path = entry.decode().split("\t", 1)
|
||||
mode, kind, oid = metadata.split()
|
||||
if path.startswith("molecule-shared/") or "/molecule/" in path:
|
||||
if kind != "blob":
|
||||
raise Uncertain(f"Unsupported Git entry: {path}")
|
||||
entries[path] = (mode, oid)
|
||||
|
||||
# Read the blobs in one process; a subprocess per scenario file is slow.
|
||||
oids = list(dict.fromkeys(oid for mode, oid in entries.values()))
|
||||
data = self.run("cat-file", "--batch", input="".join(f"{oid}\n" for oid in oids).encode())
|
||||
blobs = {}
|
||||
offset = 0
|
||||
for oid in oids:
|
||||
end = data.index(b"\n", offset)
|
||||
size = int(data[offset:end].split()[2])
|
||||
blobs[oid] = data[end + 1:end + 1 + size].decode()
|
||||
offset = end + size + 2
|
||||
return {path: (mode, blobs[oid]) for path, (mode, oid) in entries.items()}
|
||||
|
||||
|
||||
def comparison_base(git, head, env):
|
||||
"""Preserve push/PR comparison semantics, including new Renovate branches."""
|
||||
try:
|
||||
if env.get("EVENT_NAME") == "pull_request":
|
||||
return git.commit(env["BASE_SHA"])
|
||||
if env.get("EVENT_NAME") == "push":
|
||||
before = env.get("BEFORE_SHA", "")
|
||||
if before and set(before) != {"0"}:
|
||||
try:
|
||||
return git.commit(before)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
default = env.get("DEFAULT_BRANCH", "")
|
||||
if default and env.get("GITHUB_REF") != f"refs/heads/{default}":
|
||||
return git.run("merge-base", head, f"refs/remotes/origin/{default}").decode().strip()
|
||||
except (KeyError, subprocess.CalledProcessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def image_pins(snapshot):
|
||||
"""Accept only flat, quoted literal image pins; other YAML runs all scenarios."""
|
||||
if IMAGE_VARS not in snapshot or snapshot[IMAGE_VARS][0] == "120000":
|
||||
raise Uncertain("Missing or symlinked shared image pins")
|
||||
pins = {}
|
||||
document_started = False
|
||||
for line in snapshot[IMAGE_VARS][1].splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
if line == "---" and not pins and not document_started:
|
||||
document_started = True
|
||||
continue
|
||||
match = IMAGE_PIN.fullmatch(line)
|
||||
if not match or match[1] in pins:
|
||||
raise Uncertain("Unrecognized shared image-pin format")
|
||||
pins[match[1]] = match[3]
|
||||
if not pins:
|
||||
raise Uncertain("No shared image pins")
|
||||
return pins
|
||||
|
||||
|
||||
def dependencies(snapshot, role):
|
||||
"""Return file and variable dependencies, following shared files and symlinks.
|
||||
|
||||
Scan all scenario files, including nested tasks and fixtures. Ignore full-line
|
||||
comments. Do not scan vars.yml's definitions: loading the mapping does not
|
||||
mean a scenario uses every image in it.
|
||||
"""
|
||||
pending = [path for path in snapshot if path.startswith(f"roles/custom/{role}/molecule/")]
|
||||
files, variables = set(), set()
|
||||
while pending:
|
||||
path = pending.pop()
|
||||
if path in files:
|
||||
continue
|
||||
files.add(path)
|
||||
if path not in snapshot:
|
||||
raise Uncertain(f"Unresolved dependency: {path}")
|
||||
mode, content = snapshot[path]
|
||||
if mode == "120000":
|
||||
pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), content)))
|
||||
continue
|
||||
if path == IMAGE_VARS:
|
||||
continue
|
||||
content = "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#"))
|
||||
references = SHARED_PATH.findall(content)
|
||||
if content.count("molecule-shared/") != len(references):
|
||||
raise Uncertain(f"Nonliteral shared dependency in {path}")
|
||||
pending.extend(references)
|
||||
names = IMAGE_NAME.findall(content)
|
||||
if content.count("molecule_shared_image_") != len(names):
|
||||
raise Uncertain(f"Nonliteral shared image variable in {path}")
|
||||
variables.update(names)
|
||||
return files, variables
|
||||
|
||||
|
||||
def select_roles(git, base, head="HEAD", role=""):
|
||||
# Failure to enumerate the head must fail the job, never report an empty gate.
|
||||
paths = git.run("ls-tree", "-rz", "--name-only", head, "--", "roles/custom").decode().split("\0")
|
||||
available = {match[1] for path in paths if (match := SCENARIO.fullmatch(path))}
|
||||
if role:
|
||||
if role not in available:
|
||||
raise ValueError(f"No scenario at roles/custom/{role}/molecule/default")
|
||||
return [role]
|
||||
if not base:
|
||||
print("No comparison requested or available; testing every scenario", file=sys.stderr)
|
||||
return sorted(available)
|
||||
|
||||
try:
|
||||
# Disable rename detection so both old and new paths contribute consumers.
|
||||
diff = git.run("diff", "--no-renames", "--name-only", "-z", base, head, "--")
|
||||
changed = set(diff.decode().split("\0")) - {""}
|
||||
if changed & GLOBAL_FILES:
|
||||
raise Uncertain("Test infrastructure changed: " + ", ".join(sorted(changed & GLOBAL_FILES)))
|
||||
selected = {path.split("/")[2] for path in changed if path.startswith("roles/custom/")}
|
||||
shared = {path for path in changed if path.startswith("molecule-shared/")}
|
||||
if shared:
|
||||
snapshots = [git.snapshot(base), git.snapshot(head)]
|
||||
changed_images = set()
|
||||
if IMAGE_VARS in shared:
|
||||
old, new = map(image_pins, snapshots)
|
||||
if old.keys() != new.keys():
|
||||
raise Uncertain("Shared image variables added or removed")
|
||||
changed_images = {key for key in old if old[key] != new[key]}
|
||||
shared.remove(IMAGE_VARS)
|
||||
|
||||
if shared or changed_images:
|
||||
consumers = {dependency: set() for dependency in shared | changed_images}
|
||||
for snapshot in snapshots:
|
||||
for candidate in available:
|
||||
files, variables = dependencies(snapshot, candidate)
|
||||
for dependency in consumers.keys() & (files | variables):
|
||||
consumers[dependency].add(candidate)
|
||||
for dependency, roles in sorted(consumers.items()):
|
||||
if not roles:
|
||||
raise Uncertain(f"No known consumers for {dependency}")
|
||||
print(f"{dependency}: {len(roles)} scenario(s)", file=sys.stderr)
|
||||
selected.update(roles)
|
||||
return sorted(selected & available)
|
||||
except (Uncertain, subprocess.CalledProcessError, UnicodeError) as exc:
|
||||
print(f"Testing every scenario: {exc}", file=sys.stderr)
|
||||
return sorted(available)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base", help="Compare against this revision; otherwise use GitHub event variables")
|
||||
parser.add_argument("--head", default="HEAD", help="Revision to test (default: HEAD)")
|
||||
parser.add_argument("--role", default=os.environ.get("INPUT_ROLE", ""), help="Run one named role")
|
||||
args = parser.parse_args()
|
||||
git = Git(Path.cwd())
|
||||
base = args.base if args.base is not None else comparison_base(git, args.head, os.environ)
|
||||
roles = select_roles(git, base, args.head, args.role)
|
||||
result = json.dumps(roles, separators=(",", ":"))
|
||||
print(result)
|
||||
if output := os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(output, "a") as stream:
|
||||
stream.write(f"roles={result}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,246 @@
|
||||
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Regression tests for the Molecule CI matrix; no Docker or Python packages needed."""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Importing the script should not leave __pycache__ in the repository.
|
||||
sys.dont_write_bytecode = True
|
||||
SCRIPT = Path(__file__).with_name("molecule-select-roles.py")
|
||||
SPEC = importlib.util.spec_from_file_location("selector", SCRIPT)
|
||||
selector = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(selector)
|
||||
|
||||
|
||||
class SelectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.git = selector.Git(self.root)
|
||||
self.git.run("init", "--quiet", "--initial-branch=main")
|
||||
self.git.run("config", "user.name", "Molecule selector test")
|
||||
self.git.run("config", "user.email", "test@example.com")
|
||||
self.git.run("config", "commit.gpgsign", "false")
|
||||
self.roles = ["matrix-database", "matrix-livekit", "matrix-web"]
|
||||
self.write(selector.IMAGE_VARS, '\n'.join([
|
||||
'---',
|
||||
'molecule_shared_image_curl: "curl:1"',
|
||||
'molecule_shared_image_postgres: "postgres:1"',
|
||||
'molecule_shared_image_livekit: "livekit:1"',
|
||||
]) + '\n')
|
||||
self.write("molecule-shared/requirements.yml", "roles: []\n")
|
||||
self.write("molecule-shared/tasks/postgres.yml", '{{ molecule_shared_image_postgres }}\n')
|
||||
self.write("molecule-shared/tasks/probe.yml", "molecule-shared/probe.py\n")
|
||||
self.write("molecule-shared/probe.py", "print('probe')\n")
|
||||
for role in self.roles:
|
||||
self.write(f"roles/custom/{role}/molecule/default/molecule.yml", "---\n")
|
||||
self.scenario(role, "verify.yml", '{{ molecule_shared_image_curl }}\n')
|
||||
self.scenario(role, "prepare.yml", "molecule-shared/vars.yml\n")
|
||||
self.path(f"roles/custom/{role}/molecule/default/requirements.yml").symlink_to(
|
||||
"../../../../../molecule-shared/requirements.yml"
|
||||
)
|
||||
self.scenario("matrix-database", "prepare.yml", "molecule-shared/vars.yml\nmolecule-shared/tasks/postgres.yml\n")
|
||||
self.scenario("matrix-livekit", "tasks/sfu.yml", '{{ molecule_shared_image_livekit }}\n')
|
||||
self.scenario("matrix-web", "tasks/probe.yml", "molecule-shared/tasks/probe.yml\n")
|
||||
self.base = self.commit()
|
||||
self.git.run("update-ref", "refs/remotes/origin/main", self.base)
|
||||
|
||||
def path(self, name):
|
||||
return self.root / name
|
||||
|
||||
def write(self, name, content):
|
||||
path = self.path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
def scenario(self, role, name, content):
|
||||
self.write(f"roles/custom/{role}/molecule/default/{name}", content)
|
||||
|
||||
def commit(self):
|
||||
self.git.run("add", "--all")
|
||||
self.git.run("commit", "--quiet", "--allow-empty", "-m", "Test fixture")
|
||||
return self.git.commit("HEAD")
|
||||
|
||||
def bump(self, image):
|
||||
path = self.path(selector.IMAGE_VARS)
|
||||
path.write_text(path.read_text().replace(f'"{image}:1"', f'"{image}:2"'))
|
||||
|
||||
def selected(self, base=None):
|
||||
self.commit()
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
return selector.select_roles(self.git, base or self.base)
|
||||
|
||||
def test_livekit_bump_only_runs_its_consumer(self):
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), ["matrix-livekit"])
|
||||
|
||||
def test_postgres_bump_follows_shared_task(self):
|
||||
self.bump("postgres")
|
||||
self.assertEqual(self.selected(), ["matrix-database"])
|
||||
|
||||
def test_curl_bump_runs_every_consumer(self):
|
||||
self.bump("curl")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_multiple_image_bumps_union_with_direct_role_changes(self):
|
||||
self.bump("livekit")
|
||||
self.bump("postgres")
|
||||
self.write("roles/custom/matrix-web/defaults/main.yml", "version: 2\n")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_direct_changes_ignore_roles_without_scenarios(self):
|
||||
self.write("roles/custom/matrix-web/defaults/main.yml", "version: 2\n")
|
||||
self.write("roles/custom/matrix-untested/defaults/main.yml", "version: 2\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_documentation_change_runs_nothing(self):
|
||||
self.write("docs/example.md", "Documentation\n")
|
||||
self.assertEqual(self.selected(), [])
|
||||
|
||||
def test_image_pin_comments_and_quoting_do_not_run_scenarios(self):
|
||||
path = self.path(selector.IMAGE_VARS)
|
||||
path.write_text("# New comment\n" + path.read_text().replace('"', "'"))
|
||||
self.assertEqual(self.selected(), [])
|
||||
|
||||
def test_shared_task_change_only_runs_consumers(self):
|
||||
self.write("molecule-shared/tasks/postgres.yml", '{{ molecule_shared_image_postgres }}\n# Changed\n')
|
||||
self.assertEqual(self.selected(), ["matrix-database"])
|
||||
|
||||
def test_shared_fixture_change_follows_nested_references(self):
|
||||
self.write("molecule-shared/probe.py", "print('updated probe')\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_deleted_helper_uses_previous_consumers(self):
|
||||
self.path("molecule-shared/probe.py").unlink()
|
||||
self.write("molecule-shared/tasks/probe.yml", "# Probe removed\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_renamed_helper_unions_previous_and_current_consumers(self):
|
||||
self.path("molecule-shared/probe.py").rename(self.path("molecule-shared/new-probe.py"))
|
||||
self.write("molecule-shared/tasks/probe.yml", "# Probe moved\n")
|
||||
self.scenario("matrix-livekit", "tasks/new-probe.yml", "molecule-shared/new-probe.py\n")
|
||||
self.assertEqual(self.selected(), ["matrix-livekit", "matrix-web"])
|
||||
|
||||
def test_deleted_role_is_not_selected(self):
|
||||
self.path("roles/custom/matrix-livekit/molecule/default/molecule.yml").unlink()
|
||||
self.assertEqual(self.selected(), [])
|
||||
|
||||
def test_new_scenario_is_selected(self):
|
||||
self.write("roles/custom/matrix-new/molecule/default/molecule.yml", "---\n")
|
||||
self.assertEqual(self.selected(), ["matrix-new"])
|
||||
|
||||
def test_global_changes_run_all(self):
|
||||
for name in sorted(selector.GLOBAL_FILES):
|
||||
with self.subTest(name=name):
|
||||
self.git.run("reset", "--hard", self.base)
|
||||
self.write(name, "# Infrastructure changed\n")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unknown_shared_file_runs_all(self):
|
||||
self.write("molecule-shared/new-helper.yml", "---\n")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unconsumed_image_bump_runs_all(self):
|
||||
self.scenario("matrix-livekit", "tasks/sfu.yml", "# No image reference\n")
|
||||
self.base = self.commit()
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unsupported_pins_run_all(self):
|
||||
original = self.path(selector.IMAGE_VARS).read_text()
|
||||
for content in [
|
||||
'---\n' + original,
|
||||
original + 'other_setting: true\n',
|
||||
original + 'molecule_shared_image_new: "new:1"\n',
|
||||
original.replace('molecule_shared_image_livekit: "livekit:1"\n', ''),
|
||||
original + 'molecule_shared_image_curl: "curl:2"\n',
|
||||
original.replace('"livekit:1"', '"{{ another_variable }}"'),
|
||||
]:
|
||||
with self.subTest(content=content):
|
||||
self.write(selector.IMAGE_VARS, content)
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_missing_pins_run_all(self):
|
||||
self.path(selector.IMAGE_VARS).unlink()
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unresolved_references_run_all(self):
|
||||
for reference in [
|
||||
'molecule-shared/tasks/{{ helper }}.yml',
|
||||
'molecule-shared/{{ helper }}.yml',
|
||||
'molecule-shared/missing.yml',
|
||||
"{{ lookup('vars', 'molecule_shared_image_' + name) }}",
|
||||
]:
|
||||
with self.subTest(reference=reference):
|
||||
self.scenario("matrix-web", "tasks/dynamic.yml", reference)
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_shared_reference_cycles_terminate(self):
|
||||
self.write("molecule-shared/tasks/postgres.yml", 'molecule-shared/tasks/postgres.yml\n{{ molecule_shared_image_postgres }}\n')
|
||||
self.assertEqual(self.selected(), ["matrix-database"])
|
||||
|
||||
def test_comments_do_not_introduce_unresolved_references(self):
|
||||
self.scenario("matrix-web", "tasks/comments.yml", "# See molecule-shared/probe.py.\n# Helpers live in molecule-shared/.\n")
|
||||
self.base = self.commit()
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), ["matrix-livekit"])
|
||||
|
||||
def test_symlinked_shared_helper_is_followed(self):
|
||||
self.path("molecule-shared/tasks/probe.yml").unlink()
|
||||
self.path("molecule-shared/tasks/probe.yml").symlink_to("../probe.py")
|
||||
self.base = self.commit()
|
||||
self.write("molecule-shared/probe.py", "print('changed')\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_invalid_comparison_runs_all(self):
|
||||
self.assertEqual(self.selected(base="missing-commit"), self.roles)
|
||||
|
||||
def test_manual_dispatch_all_or_one_role(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(selector.select_roles(self.git, None), self.roles)
|
||||
self.assertEqual(selector.select_roles(self.git, None, role="matrix-web"), ["matrix-web"])
|
||||
with self.assertRaises(ValueError):
|
||||
selector.select_roles(self.git, None, role="../outside")
|
||||
|
||||
def test_event_comparison_bases(self):
|
||||
self.bump("livekit")
|
||||
head = self.commit()
|
||||
push = {"EVENT_NAME": "push", "DEFAULT_BRANCH": "main", "GITHUB_REF": "refs/heads/renovate/test"}
|
||||
for before in [self.base, "0" * 40, "unavailable", ""]:
|
||||
with self.subTest(before=before):
|
||||
self.assertEqual(selector.comparison_base(self.git, head, dict(push, BEFORE_SHA=before)), self.base)
|
||||
self.assertEqual(selector.comparison_base(self.git, head, {"EVENT_NAME": "pull_request", "BASE_SHA": self.base}), self.base)
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, {"EVENT_NAME": "workflow_dispatch"}))
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, dict(push, GITHUB_REF="refs/heads/main")))
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, dict(push, DEFAULT_BRANCH="missing")))
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, {"EVENT_NAME": "pull_request", "BASE_SHA": "missing"}))
|
||||
|
||||
def test_cli_writes_github_output(self):
|
||||
self.bump("livekit")
|
||||
self.commit()
|
||||
output = self.path("github-output")
|
||||
output.write_text("previous=value\n")
|
||||
env = dict(os.environ, INPUT_ROLE="", GITHUB_OUTPUT=str(output))
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--base", self.base], cwd=self.root,
|
||||
env=env, capture_output=True, text=True, check=True,
|
||||
)
|
||||
self.assertEqual(json.loads(result.stdout), ["matrix-livekit"])
|
||||
self.assertEqual(output.read_text(), 'previous=value\nroles=["matrix-livekit"]\n')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user