diff options
-rw-r--r-- | qtip/ansible_library/plugins/action/aggregate.py | 12 | ||||
-rw-r--r-- | qtip/ansible_library/plugins/action/calculate.py | 30 | ||||
-rw-r--r-- | qtip/cli/commands/cmd_plan.py | 69 | ||||
-rw-r--r-- | qtip/cli/commands/cmd_project.py | 12 | ||||
-rw-r--r-- | resources/QPI/compute.yaml | 1 | ||||
-rw-r--r-- | resources/ansible_roles/inxi/templates/system-info.j2 | 20 | ||||
-rw-r--r-- | resources/ansible_roles/qtip-workspace/files/template/templates/hosts | 2 | ||||
-rw-r--r-- | resources/ansible_roles/qtip/tasks/aggregate.yml | 7 | ||||
-rw-r--r-- | resources/ansible_roles/unixbench/templates/arithmetic-metrics.j2 | 4 | ||||
-rw-r--r-- | resources/template/qpi.html.j2 | 323 | ||||
-rw-r--r-- | tests/ci/run_ci.sh | 4 | ||||
-rw-r--r-- | tests/data/results/expected.json | 16 | ||||
-rw-r--r-- | tests/unit/ansible_library/plugins/action/calculate_test.py | 24 | ||||
-rw-r--r-- | tests/unit/cli/cmd_plan_test.py | 43 |
14 files changed, 406 insertions, 161 deletions
diff --git a/qtip/ansible_library/plugins/action/aggregate.py b/qtip/ansible_library/plugins/action/aggregate.py index f1451e06..36ea0ef1 100644 --- a/qtip/ansible_library/plugins/action/aggregate.py +++ b/qtip/ansible_library/plugins/action/aggregate.py @@ -42,9 +42,15 @@ class ActionModule(ActionBase): # aggregate QPI results @export_to_file def aggregate(hosts, basepath, src): - host_results = [{'host': host, 'result': json.load(open(os.path.join(basepath, host, src)))} for host in hosts] - score = int(mean([r['result']['score'] for r in host_results])) + host_results = [] + for host in hosts: + host_result = json.load(open(os.path.join(basepath, host, src))) + host_result['name'] = host + host_results.append(host_result) + score = int(mean([r['score'] for r in host_results])) return { 'score': score, - 'host_results': host_results + 'name': 'compute', + 'description': 'POD Compute QPI', + 'children': host_results } diff --git a/qtip/ansible_library/plugins/action/calculate.py b/qtip/ansible_library/plugins/action/calculate.py index 8d5fa1f7..077d863c 100644 --- a/qtip/ansible_library/plugins/action/calculate.py +++ b/qtip/ansible_library/plugins/action/calculate.py @@ -55,18 +55,22 @@ def calc_qpi(qpi_spec, metrics): display.vvv("spec: {}".format(qpi_spec)) display.vvv("metrics: {}".format(metrics)) - section_results = [{'name': s['name'], 'result': calc_section(s, metrics)} + section_results = [calc_section(s, metrics) for s in qpi_spec['sections']] # TODO(yujunz): use formula in spec standard_score = 2048 - qpi_score = int(mean([r['result']['score'] for r in section_results]) * standard_score) + qpi_score = int(mean([r['score'] for r in section_results]) * standard_score) results = { - 'spec': qpi_spec, 'score': qpi_score, - 'section_results': section_results, - 'metrics': metrics + 'name': qpi_spec['name'], + 'description': qpi_spec['description'], + 'children': section_results, + 'details': { + 'metrics': metrics, + 'spec': "https://git.opnfv.org/qtip/tree/resources/QPI/compute.yaml" + } } return results @@ -78,13 +82,15 @@ def calc_section(section_spec, metrics): display.vvv("spec: {}".format(section_spec)) display.vvv("metrics: {}".format(metrics)) - metric_results = [{'name': m['name'], 'result': calc_metric(m, metrics[m['name']])} + metric_results = [calc_metric(m, metrics[m['name']]) for m in section_spec['metrics']] # TODO(yujunz): use formula in spec - section_score = mean([r['result']['score'] for r in metric_results]) + section_score = mean([r['score'] for r in metric_results]) return { 'score': section_score, - 'metric_results': metric_results + 'name': section_spec['name'], + 'description': section_spec.get('description', 'section'), + 'children': metric_results } @@ -95,12 +101,16 @@ def calc_metric(metric_spec, metrics): display.vvv("metrics: {}".format(metrics)) # TODO(yujunz): use formula in spec - workload_results = [{'name': w['name'], 'score': calc_score(metrics[w['name']], w['baseline'])} + workload_results = [{'name': w['name'], + 'description': 'workload', + 'score': calc_score(metrics[w['name']], w['baseline'])} for w in metric_spec['workloads']] metric_score = mean([r['score'] for r in workload_results]) return { 'score': metric_score, - 'workload_results': workload_results + 'name': metric_spec['name'], + 'description': metric_spec.get('description', 'metric'), + 'children': workload_results } diff --git a/qtip/cli/commands/cmd_plan.py b/qtip/cli/commands/cmd_plan.py deleted file mode 100644 index b7c540b7..00000000 --- a/qtip/cli/commands/cmd_plan.py +++ /dev/null @@ -1,69 +0,0 @@ -############################################################################## -# Copyright (c) 2016 taseer94@gmail.com and others. -# -# All rights reserved. This program and the accompanying materials -# are made available under the terms of the Apache License, Version 2.0 -# which accompanies this distribution, and is available at -# http://www.apache.org/licenses/LICENSE-2.0 -############################################################################## - - -import click -from colorama import Fore -import os - -from qtip.base.error import InvalidContentError -from qtip.base.error import NotFoundError -from qtip.cli import utils -from qtip.cli.entry import Context -from qtip.loader.plan import Plan - - -pass_context = click.make_pass_decorator(Context, ensure=False) - - -@click.group() -@pass_context -def cli(ctx): - ''' Bechmarking Plan ''' - pass - - -@cli.command('init', help='Initialize Environment') -@pass_context -def init(ctx): - pass - - -@cli.command('list', help='List the Plans') -@pass_context -def list(ctx): - plans = Plan.list_all() - table = utils.table('Plans', plans) - click.echo(table) - - -@cli.command('show', help='View details of a Plan') -@click.argument('name') -@pass_context -def show(ctx, name): - try: - plan = Plan('{}.yaml'.format(name)) - except NotFoundError as nf: - click.echo(Fore.RED + "ERROR: plan spec: " + nf.message) - except InvalidContentError as ice: - click.echo(Fore.RED + "ERROR: plan spec: " + ice.message) - else: - cnt = plan.content - output = utils.render('plan', cnt) - click.echo(output) - - -@cli.command('run', help='Execute a Plan') -@click.argument('name') -@click.option('-p', '--path', help='Path to store results') -@pass_context -def run(ctx, name, path): - runner_path = os.path.join(os.path.dirname(__file__), os.path.pardir, os.path.pardir, - 'runner/runner.py') - os.system('python {0} -b all -d {1}'.format(runner_path, path)) diff --git a/qtip/cli/commands/cmd_project.py b/qtip/cli/commands/cmd_project.py index 42fd000d..117330f5 100644 --- a/qtip/cli/commands/cmd_project.py +++ b/qtip/cli/commands/cmd_project.py @@ -36,10 +36,14 @@ def cli(): @cli.command(help="Create new testing project") -@click.option('--pod', default='unknown', help='Name of pod under test') -@click.option('--installer', help='OPNFV installer', default='manual') -@click.option('--master-host', help='Installer hostname', default='dummy-host') -@click.option('--scenario', default='unknown', help='OPNFV scenario') +@click.option('--pod', default='unknown', prompt='Pod Name', + help='Name of pod under test') +@click.option('--installer', prompt='OPNFV Installer', + help='OPNFV installer', default='manual') +@click.option('--master-host', prompt='Installer Hostname', + help='Installer hostname', default='dummy-host') +@click.option('--scenario', prompt='OPNFV Scenario', default='unknown', + help='OPNFV scenario') @click.argument('name') def create(pod, installer, master_host, scenario, name): extra_vars = { diff --git a/resources/QPI/compute.yaml b/resources/QPI/compute.yaml index bc4e8ab2..d27d769b 100644 --- a/resources/QPI/compute.yaml +++ b/resources/QPI/compute.yaml @@ -18,7 +18,6 @@ sections: # split based on different application formual: geometric mean workloads: - name: rsa_sign_512 - description: RSA signature 512 bits baseline: 14982.3 - name: rsa_verify_512 baseline: 180619.2 diff --git a/resources/ansible_roles/inxi/templates/system-info.j2 b/resources/ansible_roles/inxi/templates/system-info.j2 index 305a2af2..2108a979 100644 --- a/resources/ansible_roles/inxi/templates/system-info.j2 +++ b/resources/ansible_roles/inxi/templates/system-info.j2 @@ -1,16 +1,10 @@ System Information from inxi ============================ -{% for host in groups['compute'] %} -{{ hostvars[host].ansible_hostname }} ------------------------------ - -{{ ('CPU Brand', hostvars[host].system_info.cpu[0])|justify }} -{{ ('Disk', hostvars[host].system_info.disk[0])|justify }} -{{ ('Host Name', hostvars[host].system_info.hostname[0])|justify }} -{{ ('Kernel', hostvars[host].system_info.kernel[0])|justify }} -{{ ('Memory', hostvars[host].system_info.memory[0])|justify }} -{{ ('Operating System', hostvars[host].system_info.os[0])|justify }} -{{ ('Product', hostvars[host].system_info.product[0])|justify }} - -{% endfor %} +{{ ('CPU Brand', system_info.cpu[0])|justify }} +{{ ('Disk', system_info.disk[0])|justify }} +{{ ('Host Name', system_info.hostname[0])|justify }} +{{ ('Kernel', system_info.kernel[0])|justify }} +{{ ('Memory', system_info.memory[0])|justify }} +{{ ('Operating System', system_info.os[0])|justify }} +{{ ('Product', system_info.product[0])|justify }} diff --git a/resources/ansible_roles/qtip-workspace/files/template/templates/hosts b/resources/ansible_roles/qtip-workspace/files/template/templates/hosts index 492651b0..34e4aa92 100644 --- a/resources/ansible_roles/qtip-workspace/files/template/templates/hosts +++ b/resources/ansible_roles/qtip-workspace/files/template/templates/hosts @@ -4,7 +4,7 @@ localhost ansible_connection=local [{{ installer_master_group[installer] }}] {{ installer_master_host }} -[SUT] # system under test +[SUT:children] # system under test compute [node-groups:children] diff --git a/resources/ansible_roles/qtip/tasks/aggregate.yml b/resources/ansible_roles/qtip/tasks/aggregate.yml index 9ecdc700..904fc5d6 100644 --- a/resources/ansible_roles/qtip/tasks/aggregate.yml +++ b/resources/ansible_roles/qtip/tasks/aggregate.yml @@ -14,5 +14,10 @@ group: compute basepath: "{{ qtip_results }}/current" src: "compute.json" - dest: "{{ pod_name }}-qpi.json" + dest: "qpi.json" register: pod_result + +- name: generating HTML report + template: + src: "{{ qtip_resources }}/template/qpi.html.j2" + dest: "{{ qtip_results }}/current/index.html" diff --git a/resources/ansible_roles/unixbench/templates/arithmetic-metrics.j2 b/resources/ansible_roles/unixbench/templates/arithmetic-metrics.j2 index a12eb0ab..c2c4c3b2 100644 --- a/resources/ansible_roles/unixbench/templates/arithmetic-metrics.j2 +++ b/resources/ansible_roles/unixbench/templates/arithmetic-metrics.j2 @@ -1,5 +1,5 @@ Arithmetic ========== -{{ ('Floating-point (Whetstone MWIPS)', arithmetic_metrics.dhrystone_lps[0])|justify }} -{{ ('Integer (Dhyrstone lps)', arithmetic_metrics.whetstone_MWIPS[0])|justify }} +{{ ('Floating-point (Whetstone MWIPS)', arithmetic_metrics.whetstone_MWIPS[0])|justify }} +{{ ('Integer (Dhyrstone lps)', arithmetic_metrics.dhrystone_lps[0])|justify }} diff --git a/resources/template/qpi.html.j2 b/resources/template/qpi.html.j2 new file mode 100644 index 00000000..3515676a --- /dev/null +++ b/resources/template/qpi.html.j2 @@ -0,0 +1,323 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<style> + + circle, + path { + cursor: pointer; + } + + circle { + fill: none; + pointer-events: all; + } + + #tooltip { + background-color: white; + padding: 3px 5px; + border: 1px solid black; + text-align: center; + } + + html { + font-family: sans-serif; + + } +</style> +<body> +<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script> +<script> + + var margin = {top: 350, right: 480, bottom: 350, left: 480}, + radius = Math.min(margin.top, margin.right, margin.bottom, margin.left) - 10; + + function filter_min_arc_size_text(d, i) { + return (d.dx * d.depth * radius / 3) > 14 + }; + + var hue = d3.scale.category10(); + + var luminance = d3.scale.sqrt() + .domain([0, 1e6]) + .clamp(true) + .range([90, 20]); + + var svg = d3.select("body").append("svg") + .attr("width", margin.left + margin.right) + .attr("height", margin.top + margin.bottom) + .append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + var partition = d3.layout.partition() + .sort(function (a, b) { + return d3.ascending(a.name, b.name); + }) + .size([2 * Math.PI, radius]); + + var arc = d3.svg.arc() + .startAngle(function (d) { + return d.x; + }) + .endAngle(function (d) { + return d.x + d.dx - .01 / (d.depth + .5); + }) + .innerRadius(function (d) { + return radius / 3 * d.depth; + }) + .outerRadius(function (d) { + return radius / 3 * (d.depth + 1) - 1; + }); + + //Tooltip description + var tooltip = d3.select("body") + .append("div") + .attr("id", "tooltip") + .style("position", "absolute") + .style("z-index", "10") + .style("opacity", 0); + + function format_number(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + + + function format_description(d) { + var description = d.description; + return '<b>' + d.name + '</b></br>' + d.description + '<br> (' + format_number(d.value) + ')'; + } + + function computeTextRotation(d) { + var angle = (d.x + d.dx / 2) * 180 / Math.PI - 90 + + return angle; + } + + function mouseOverArc(d) { + d3.select(this).attr("stroke", "black") + + tooltip.html(format_description(d)); + return tooltip.transition() + .duration(50) + .style("opacity", 0.9); + } + + function mouseOutArc() { + d3.select(this).attr("stroke", "") + return tooltip.style("opacity", 0); + } + + function mouseMoveArc(d) { + return tooltip + .style("top", (d3.event.pageY - 10) + "px") + .style("left", (d3.event.pageX + 10) + "px"); + } + + var root_ = null; + d3.json("qpi.json", function (error, root) { + if (error) return console.warn(error); + // Compute the initial layout on the entire tree to sum sizes. + // Also compute the full name and fill color for each node, + // and stash the children so they can be restored as we descend. + + partition + .value(function (d) { + return d.score; + }) + .nodes(root) + .forEach(function (d) { + d._children = d.children; + d.sum = d.value; + d.key = key(d); + d.fill = fill(d); + }); + + // Now redefine the value function to use the previously-computed sum. + partition + .children(function (d, depth) { + return depth < 2 ? d._children : null; + }) + .value(function (d) { + return d.sum; + }); + + var center = svg.append("circle") + .attr("r", radius / 3) + .on("click", zoomOut); + + center.append("title") + .text("zoom out"); + + var partitioned_data = partition.nodes(root).slice(1) + + var path = svg.selectAll("path") + .data(partitioned_data) + .enter().append("path") + .attr("d", arc) + .style("fill", function (d) { + return d.fill; + }) + .each(function (d) { + this._current = updateArc(d); + }) + .on("click", zoomIn) + .on("mouseover", mouseOverArc) + .on("mousemove", mouseMoveArc) + .on("mouseout", mouseOutArc); + + + var texts = svg.selectAll("text") + .data(partitioned_data) + .enter().append("text") + .filter(filter_min_arc_size_text) + .attr("transform", function (d) { + return "rotate(" + computeTextRotation(d) + ")"; + }) + .attr("x", function (d) { + return radius / 3 * d.depth; + }) + .attr("dx", "6") // margin + .attr("dy", ".35em") // vertical-align + .text(function (d, i) { + return d.name + }) + + function zoomIn(p) { + if (p.depth > 1) p = p.parent; + if (!p.children) return; + zoom(p, p); + } + + function zoomOut(p) { + if (!p.parent) return; + zoom(p.parent, p); + } + + // Zoom to the specified new root. + function zoom(root, p) { + if (document.documentElement.__transition__) return; + + // Rescale outside angles to match the new layout. + var enterArc, + exitArc, + outsideAngle = d3.scale.linear().domain([0, 2 * Math.PI]); + + function insideArc(d) { + return p.key > d.key + ? {depth: d.depth - 1, x: 0, dx: 0} : p.key < d.key + ? {depth: d.depth - 1, x: 2 * Math.PI, dx: 0} + : {depth: 0, x: 0, dx: 2 * Math.PI}; + } + + function outsideArc(d) { + return {depth: d.depth + 1, x: outsideAngle(d.x), dx: outsideAngle(d.x + d.dx) - outsideAngle(d.x)}; + } + + center.datum(root); + + // When zooming in, arcs enter from the outside and exit to the inside. + // Entering outside arcs start from the old layout. + if (root === p) enterArc = outsideArc, exitArc = insideArc, outsideAngle.range([p.x, p.x + p.dx]); + + var new_data = partition.nodes(root).slice(1) + + path = path.data(new_data, function (d) { + return d.key; + }); + + // When zooming out, arcs enter from the inside and exit to the outside. + // Exiting outside arcs transition to the new layout. + if (root !== p) enterArc = insideArc, exitArc = outsideArc, outsideAngle.range([p.x, p.x + p.dx]); + + d3.transition().duration(d3.event.altKey ? 7500 : 750).each(function () { + path.exit().transition() + .style("fill-opacity", function (d) { + return d.depth === 1 + (root === p) ? 1 : 0; + }) + .attrTween("d", function (d) { + return arcTween.call(this, exitArc(d)); + }) + .remove(); + + path.enter().append("path") + .style("fill-opacity", function (d) { + return d.depth === 2 - (root === p) ? 1 : 0; + }) + .style("fill", function (d) { + return d.fill; + }) + .on("click", zoomIn) + .on("mouseover", mouseOverArc) + .on("mousemove", mouseMoveArc) + .on("mouseout", mouseOutArc) + .each(function (d) { + this._current = enterArc(d); + }); + + + path.transition() + .style("fill-opacity", 1) + .attrTween("d", function (d) { + return arcTween.call(this, updateArc(d)); + }); + + + }); + + + texts = texts.data(new_data, function (d) { + return d.key; + }) + + texts.exit() + .remove() + texts.enter() + .append("text") + + texts.style("opacity", 0) + .attr("transform", function (d) { + return "rotate(" + computeTextRotation(d) + ")"; + }) + .attr("x", function (d) { + return radius / 3 * d.depth; + }) + .attr("dx", "6") // margin + .attr("dy", ".35em") // vertical-align + .filter(filter_min_arc_size_text) + .text(function (d, i) { + return d.name + }) + .transition().delay(750).style("opacity", 1) + + + } + }); + + function key(d) { + var k = [], p = d; + while (p.depth) k.push(p.name), p = p.parent; + return k.reverse().join("."); + } + + function fill(d) { + var p = d; + while (p.depth > 1) p = p.parent; + var c = d3.lab(hue(p.name)); + c.l = luminance(d.sum); + return c; + } + + function arcTween(b) { + var i = d3.interpolate(this._current, b); + this._current = i(0); + return function (t) { + return arc(i(t)); + }; + } + + function updateArc(d) { + return {depth: d.depth, x: d.x, dx: d.dx}; + } + + d3.select(self.frameElement).style("height", margin.top + margin.bottom + "px"); + +</script>
\ No newline at end of file diff --git a/tests/ci/run_ci.sh b/tests/ci/run_ci.sh index 8fd53b36..163adb39 100644 --- a/tests/ci/run_ci.sh +++ b/tests/ci/run_ci.sh @@ -45,6 +45,8 @@ done #set vars from env if not provided by user as options installer_type=${installer_type:-$INSTALLER_TYPE} installer_ip=${installer_ip:-$INSTALLER_IP} +pod_name=${pod_name:-$POD_NAME} +scenario=${scenario:-$SCENARIO} sshoptions="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" @@ -65,7 +67,7 @@ esac cd /home/opnfv -qtip workspace create --pod ${pod_name} --installer ${installer_type} \ +qtip create --pod ${pod_name} --installer ${installer_type} \ --master-host ${installer_ip} --scenario ${scenario} workspace cd /home/opnfv/workspace/ diff --git a/tests/data/results/expected.json b/tests/data/results/expected.json index a495d999..e77200d4 100644 --- a/tests/data/results/expected.json +++ b/tests/data/results/expected.json @@ -1,7 +1,15 @@ { "score": 150, - "host_results": [ - {"host": "host1", "result": {"score": 100}}, - {"host": "host2", "result": {"score": 200}} - ] + "children": [ + { + "name": "host1", + "score": 100 + }, + { + "name": "host2", + "score": 200 + } + ], + "description": "POD Compute QPI", + "name": "compute" } diff --git a/tests/unit/ansible_library/plugins/action/calculate_test.py b/tests/unit/ansible_library/plugins/action/calculate_test.py index 68a03e2a..fae59821 100644 --- a/tests/unit/ansible_library/plugins/action/calculate_test.py +++ b/tests/unit/ansible_library/plugins/action/calculate_test.py @@ -45,8 +45,8 @@ def section_spec(metric_spec): @pytest.fixture def qpi_spec(section_spec): return { - "description": "QTIP Performance Index of compute", "name": "compute", + "description": "QTIP Performance Index of compute", "sections": [section_spec] } @@ -54,23 +54,29 @@ def qpi_spec(section_spec): @pytest.fixture() def metric_result(): return {'score': 1.0, - 'workload_results': [ - {'name': 'rsa_sign', 'score': 1.0}, - {'name': 'rsa_verify', 'score': 1.0}]} + 'name': 'ssl_rsa', + 'description': 'metric', + 'children': [{'description': 'workload', 'name': 'rsa_sign', 'score': 1.0}, + {'description': 'workload', 'name': 'rsa_verify', 'score': 1.0}]} @pytest.fixture() def section_result(metric_result): return {'score': 1.0, - 'metric_results': [{'name': 'ssl_rsa', 'result': metric_result}]} + 'name': 'ssl', + 'description': 'cryptography and SSL/TLS performance', + 'children': [metric_result]} @pytest.fixture() -def qpi_result(qpi_spec, section_result, metrics): +def qpi_result(section_result, metrics): return {'score': 2048, - 'spec': qpi_spec, - 'metrics': metrics, - 'section_results': [{'name': 'ssl', 'result': section_result}]} + 'name': 'compute', + 'description': 'QTIP Performance Index of compute', + 'children': [section_result], + 'details': { + 'spec': "https://git.opnfv.org/qtip/tree/resources/QPI/compute.yaml", + 'metrics': metrics}} def test_calc_metric(metric_spec, metrics, metric_result): diff --git a/tests/unit/cli/cmd_plan_test.py b/tests/unit/cli/cmd_plan_test.py deleted file mode 100644 index 53a04800..00000000 --- a/tests/unit/cli/cmd_plan_test.py +++ /dev/null @@ -1,43 +0,0 @@ -############################################################### -# Copyright (c) 2017 taseer94@gmail.com and others. -# -# All rights reserved. This program and the accompanying materials -# are made available under the terms of the Apache License, Version 2.0 -# which accompanies this distribution, and is available at -# http://www.apache.org/licenses/LICENSE-2.0 -############################################################################## - -import pytest -from click.testing import CliRunner - -from qtip.cli.entry import cli - - -@pytest.fixture(scope="module") -def runner(): - return CliRunner() - - -def test_list(runner): - result = runner.invoke(cli, ['plan', 'list']) - assert 'Plan' and 'compute' and 'sample' in result.output - - -def test_run(runner): - result = runner.invoke(cli, ['plan', 'run', 'fake-plan']) - assert result.output == '' - - result = runner.invoke(cli, ['plan', 'run']) - assert 'Missing argument "name".' in result.output - - -def test_show(runner): - result = runner.invoke(cli, ['plan', 'show', 'compute']) - assert 'Name: compute QPI' in result.output - assert 'Description: compute QPI profile' - - result = runner.invoke(cli, ['plan', 'show']) - assert 'Missing argument "name".' in result.output - - result = runner.invoke(cli, ['plan', 'show', 'xyz']) - assert "ERROR: plan spec: xyz not found" in result.output |