mirror of
https://github.com/haidaraM/ansible-playbook-grapher
synced 2024-11-10 06:04:15 +00:00
feat: Open node file when double-clicking on it from a browser (#79)
This commit is contained in:
parent
2ce66443dc
commit
826c6ef284
13 changed files with 394 additions and 76 deletions
|
@ -1,6 +1,10 @@
|
|||
# 1.1.0 (unreleased)
|
||||
|
||||
- fix: Do not pass display as param since it's a singleton + init locale to fix warning
|
||||
- feat: Open node file when double-clicking on it from a
|
||||
browser [\#79](https://github.com/haidaraM/ansible-playbook-grapher/pull/79)
|
||||
|
||||
- **Full Changelog**: https://github.com/haidaraM/ansible-playbook-grapher/compare/v1.0.2...v1.1.0
|
||||
|
||||
# 1.0.2 (2022-01-16)
|
||||
|
||||
|
|
39
README.md
39
README.md
|
@ -61,13 +61,11 @@ regarding the blocks.
|
|||
The available options:
|
||||
|
||||
```
|
||||
$ ansible-playbook-grapher --help
|
||||
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY]
|
||||
[--include-role-tasks] [-s] [--view]
|
||||
[-o OUTPUT_FILENAME] [--version] [-t TAGS]
|
||||
[--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
|
||||
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES]
|
||||
[-e EXTRA_VARS]
|
||||
ansible-playbook-grapher --help
|
||||
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY] [--include-role-tasks] [-s] [--view] [-o OUTPUT_FILENAME]
|
||||
[--open-protocol-handler {default,vscode,custom}] [--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS] [--version]
|
||||
[-t TAGS] [--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
|
||||
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
|
||||
playbook
|
||||
|
||||
Make graphs from your Ansible Playbooks.
|
||||
|
@ -79,29 +77,32 @@ optional arguments:
|
|||
--ask-vault-password, --ask-vault-pass
|
||||
ask for vault password
|
||||
--include-role-tasks Include the tasks of the role in the graph.
|
||||
--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS
|
||||
The custom formats to use as URLs for the nodes in the graph. Required if --open-protocol-handler is set to custom. You should
|
||||
provide a JSON formatted string like: {"file": "", "folder": ""}. Example: If you want to open folders (roles) inside the browser
|
||||
and files (tasks) in vscode, set this to '{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
|
||||
--open-protocol-handler {default,vscode,custom}
|
||||
The protocol to use to open the nodes when double-clicking on them in your SVG viewer. Your SVG viewer must support double-click
|
||||
and Javascript. The supported values are 'default', 'vscode' and 'custom'. For 'default', the URL will be the path to the file or
|
||||
folders. When using a browser, it will open or download them. For 'vscode', the folders and files will be open with VSCode. For
|
||||
'custom', you need to set a custom format with --open-protocol-custom-formats.
|
||||
--skip-tags SKIP_TAGS
|
||||
only run plays and tasks whose tags do not match these
|
||||
values
|
||||
only run plays and tasks whose tags do not match these values
|
||||
--vault-id VAULT_IDS the vault identity to use
|
||||
--vault-password-file VAULT_PASSWORD_FILES, --vault-pass-file VAULT_PASSWORD_FILES
|
||||
vault password file
|
||||
--version show program's version number and exit
|
||||
--view Automatically open the resulting SVG file with your
|
||||
system’s default viewer application for the file type
|
||||
--view Automatically open the resulting SVG file with your system’s default viewer application for the file type
|
||||
-e EXTRA_VARS, --extra-vars EXTRA_VARS
|
||||
set additional variables as key=value or YAML/JSON, if
|
||||
filename prepend with @
|
||||
set additional variables as key=value or YAML/JSON, if filename prepend with @
|
||||
-h, --help show this help message and exit
|
||||
-i INVENTORY, --inventory INVENTORY
|
||||
specify inventory host path or comma separated host
|
||||
list.
|
||||
specify inventory host path or comma separated host list.
|
||||
-o OUTPUT_FILENAME, --output-file-name OUTPUT_FILENAME
|
||||
Output filename without the '.svg' extension. Default:
|
||||
<playbook>.svg
|
||||
Output filename without the '.svg' extension. Default: <playbook>.svg
|
||||
-s, --save-dot-file Save the dot file used to generate the graph.
|
||||
-t TAGS, --tags TAGS only run plays and tasks tagged with these values
|
||||
-v, --verbose verbose mode (-vvv for more, -vvvv to enable
|
||||
connection debugging)
|
||||
-v, --verbose verbose mode (-vvv for more, -vvvv to enable connection debugging)
|
||||
```
|
||||
|
||||
## Configuration: ansible.cfg
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import sys
|
||||
|
@ -19,13 +20,18 @@ from abc import ABC
|
|||
|
||||
from ansible.cli import CLI
|
||||
from ansible.cli.arguments import option_helpers
|
||||
from ansible.errors import AnsibleOptionsError
|
||||
from ansible.release import __version__ as ansible_version
|
||||
from ansible.utils.display import Display, initialize_locale
|
||||
|
||||
from ansibleplaybookgrapher import __prog__, __version__
|
||||
from ansibleplaybookgrapher.parser import PlaybookParser
|
||||
from ansibleplaybookgrapher.postprocessor import GraphVizPostProcessor
|
||||
from ansibleplaybookgrapher.renderer import GraphvizRenderer
|
||||
from ansibleplaybookgrapher.renderer import GraphvizRenderer, OPEN_PROTOCOL_HANDLERS
|
||||
|
||||
# The display is a singleton. This instruction will NOT return a new instance.
|
||||
# We explicitly set the verbosity after the init.
|
||||
display = Display()
|
||||
|
||||
|
||||
def get_cli_class():
|
||||
|
@ -45,9 +51,6 @@ class GrapherCLI(CLI, ABC):
|
|||
def run(self):
|
||||
super(GrapherCLI, self).run()
|
||||
|
||||
# The display is a singleton. This instruction will NOT return a new instance.
|
||||
# We explicitly set the verbosity after the init.
|
||||
display = Display()
|
||||
# Required to fix the warning "ansible.utils.display.initialize_locale has not been called..."
|
||||
initialize_locale()
|
||||
display.verbosity = self.options.verbosity
|
||||
|
@ -57,7 +60,8 @@ class GrapherCLI(CLI, ABC):
|
|||
include_role_tasks=self.options.include_role_tasks)
|
||||
|
||||
playbook_node = parser.parse()
|
||||
renderer = GraphvizRenderer(playbook_node)
|
||||
renderer = GraphvizRenderer(playbook_node, open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats)
|
||||
svg_path = renderer.render(self.options.output_filename, self.options.save_dot_file, self.options.view)
|
||||
|
||||
post_processor = GraphVizPostProcessor(svg_path=svg_path)
|
||||
|
@ -86,7 +90,6 @@ class PlaybookGrapherCLI(GrapherCLI):
|
|||
def _add_my_options(self):
|
||||
"""
|
||||
Add some of my options to the parser
|
||||
:param parser:
|
||||
:return:
|
||||
"""
|
||||
self.parser.prog = __prog__
|
||||
|
@ -106,6 +109,26 @@ class PlaybookGrapherCLI(GrapherCLI):
|
|||
self.parser.add_argument("-o", "--output-file-name", dest='output_filename',
|
||||
help="Output filename without the '.svg' extension. Default: <playbook>.svg")
|
||||
|
||||
self.parser.add_argument("--open-protocol-handler", dest="open_protocol_handler",
|
||||
choices=list(OPEN_PROTOCOL_HANDLERS.keys()), default="default",
|
||||
help="""The protocol to use to open the nodes when double-clicking on them in your SVG
|
||||
viewer. Your SVG viewer must support double-click and Javascript.
|
||||
The supported values are 'default', 'vscode' and 'custom'.
|
||||
For 'default', the URL will be the path to the file or folders. When using a browser,
|
||||
it will open or download them.
|
||||
For 'vscode', the folders and files will be open with VSCode.
|
||||
For 'custom', you need to set a custom format with --open-protocol-custom-formats.
|
||||
""")
|
||||
|
||||
self.parser.add_argument("--open-protocol-custom-formats", dest="open_protocol_custom_formats", default=None,
|
||||
help="""The custom formats to use as URLs for the nodes in the graph. Required if
|
||||
--open-protocol-handler is set to custom.
|
||||
You should provide a JSON formatted string like: {"file": "", "folder": ""}.
|
||||
Example: If you want to open folders (roles) inside the browser and files (tasks) in
|
||||
vscode, set this to
|
||||
'{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
|
||||
""")
|
||||
|
||||
self.parser.add_argument('--version', action='version',
|
||||
version="%s %s (with ansible %s)" % (__prog__, __version__, ansible_version))
|
||||
|
||||
|
@ -132,8 +155,36 @@ class PlaybookGrapherCLI(GrapherCLI):
|
|||
# use the playbook name (without the extension) as output filename
|
||||
self.options.output_filename = os.path.splitext(ntpath.basename(self.options.playbook_filename))[0]
|
||||
|
||||
if self.options.open_protocol_handler == "custom":
|
||||
self.validate_open_protocol_custom_formats()
|
||||
|
||||
return options
|
||||
|
||||
def validate_open_protocol_custom_formats(self):
|
||||
"""
|
||||
Validate the provided open protocol format
|
||||
:return:
|
||||
"""
|
||||
error_msg = 'Make sure to provide valid formats. Example: {"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
|
||||
format_str = self.options.open_protocol_custom_formats
|
||||
if not format_str:
|
||||
raise AnsibleOptionsError("When the protocol handler is to set to custom, you must provide the formats to "
|
||||
"use with --open-protocol-custom-formats.")
|
||||
try:
|
||||
format_dict = json.loads(format_str)
|
||||
except Exception as e:
|
||||
display.error(f"{type(e).__name__} when reading the provided formats '{format_str}': {e}")
|
||||
display.error(error_msg)
|
||||
sys.exit(1)
|
||||
|
||||
if "file" not in format_dict or "folder" not in format_dict:
|
||||
display.error(f"The field 'file' or 'folder' is missing from the provided format '{format_str}'")
|
||||
display.error(error_msg)
|
||||
sys.exit(1)
|
||||
|
||||
# Replace the string with a dict
|
||||
self.options.open_protocol_custom_formats = format_dict
|
||||
|
||||
|
||||
def main(args=None):
|
||||
args = args or sys.argv
|
||||
|
|
|
@ -52,7 +52,7 @@ function unHighlightLinkedNodes(rootElement, isHover) {
|
|||
// Recursively unhighlight
|
||||
unHighlightLinkedNodes(linkedElement, isHover);
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -60,7 +60,7 @@ function unHighlightLinkedNodes(rootElement, isHover) {
|
|||
|
||||
/**
|
||||
* Hover handler for mouseenter event
|
||||
* @param event
|
||||
* @param {Event} event
|
||||
*/
|
||||
function hoverMouseEnter(event) {
|
||||
highlightLinkedNodes(event.currentTarget);
|
||||
|
@ -68,7 +68,7 @@ function hoverMouseEnter(event) {
|
|||
|
||||
/**
|
||||
* Hover handler for mouseleave event
|
||||
* @param event
|
||||
* @param {Event} event
|
||||
*/
|
||||
function hoverMouseLeave(event) {
|
||||
unHighlightLinkedNodes(event.currentTarget, true);
|
||||
|
@ -76,10 +76,12 @@ function hoverMouseLeave(event) {
|
|||
|
||||
/**
|
||||
* Handler when clicking on some elements
|
||||
* @param event
|
||||
* @param {Event} event
|
||||
*/
|
||||
function clickOnElement(event) {
|
||||
let newClickedElement = $(event.currentTarget);
|
||||
const newClickedElement = $(event.currentTarget);
|
||||
|
||||
event.preventDefault(); // Disable the default click behavior since we override it here
|
||||
|
||||
if (newClickedElement.attr('id') === $(currentSelectedElement).attr('id')) { // clicking again on the same element
|
||||
newClickedElement.removeClass(HIGHLIGHT_CLASS);
|
||||
|
@ -87,7 +89,7 @@ function clickOnElement(event) {
|
|||
currentSelectedElement = null;
|
||||
} else { // clicking on a different node
|
||||
|
||||
// Remove highlight from all the nodes linked the current selected node
|
||||
// Remove highlight from all the nodes linked to the current selected node
|
||||
unHighlightLinkedNodes(currentSelectedElement, false);
|
||||
if (currentSelectedElement) {
|
||||
currentSelectedElement.removeClass(HIGHLIGHT_CLASS);
|
||||
|
@ -99,22 +101,51 @@ function clickOnElement(event) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler when double clicking on some elements
|
||||
* @param {Event} event
|
||||
*/
|
||||
function dblClickElement(event) {
|
||||
const newElementDlbClicked = event.currentTarget;
|
||||
const links = $(newElementDlbClicked).find("a[xlink\\:href]");
|
||||
|
||||
if (links.length > 0) {
|
||||
const targetLink = $(links[0]).attr("xlink:href");
|
||||
document.location = targetLink;
|
||||
} else {
|
||||
console.log("No links found on this element");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$("#svg").ready(function () {
|
||||
let playbook = $("g[id^=playbook_]");
|
||||
let plays = $("g[id^=play_]");
|
||||
let roles = $("g[id^=role_]");
|
||||
let blocks = $("g[id^=block_]");
|
||||
let tasks = $("g[id^=pre_task_], g[id^=task_], g[id^=post_task_]");
|
||||
|
||||
playbook.click(clickOnElement);
|
||||
playbook.dblclick(dblClickElement);
|
||||
|
||||
// Set hover and click events on the plays
|
||||
plays.hover(hoverMouseEnter, hoverMouseLeave);
|
||||
plays.click(clickOnElement);
|
||||
plays.dblclick(dblClickElement);
|
||||
|
||||
// Set hover and click events on the roles
|
||||
roles.hover(hoverMouseEnter, hoverMouseLeave);
|
||||
roles.click(clickOnElement);
|
||||
roles.dblclick(dblClickElement);
|
||||
|
||||
// Set hover and click events on the blocks
|
||||
blocks.hover(hoverMouseEnter, hoverMouseLeave);
|
||||
blocks.click(clickOnElement);
|
||||
blocks.dblclick(dblClickElement);
|
||||
|
||||
// Set hover and click events on the tasks
|
||||
tasks.hover(hoverMouseEnter, hoverMouseLeave);
|
||||
tasks.click(clickOnElement);
|
||||
tasks.dblclick(dblClickElement);
|
||||
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, ItemsView
|
||||
|
||||
|
@ -28,12 +29,26 @@ class Node:
|
|||
|
||||
:param node_name: The name of the node
|
||||
:param node_id: An identifier for this node
|
||||
:param raw_object: The raw ansible object matching this node in the graph. Will be None if there is no match on
|
||||
:param raw_object: The raw ansible object matching this node in the graph. Will be None if there is no match on
|
||||
Ansible side
|
||||
"""
|
||||
self.name = node_name
|
||||
self.id = node_id
|
||||
self.raw_object = raw_object
|
||||
# Trying to get the object position in the parsed files. Format: (path,line,column)
|
||||
self.path = self.line = self.column = None
|
||||
self.retrieve_position()
|
||||
|
||||
def retrieve_position(self):
|
||||
"""
|
||||
Set the path of this based on the raw object. Not all objects have path
|
||||
:return:
|
||||
"""
|
||||
if self.raw_object and self.raw_object.get_ds():
|
||||
self.path, self.line, self.column = self.raw_object.get_ds().ansible_pos
|
||||
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}(name='{self.name}')"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{type(self).__name__}(id='{self.id}',name='{self.name}')"
|
||||
|
@ -142,6 +157,16 @@ class PlaybookNode(CompositeNode):
|
|||
super().__init__(node_name, node_id or generate_id("playbook_"), raw_object=raw_object,
|
||||
supported_compositions=["plays"])
|
||||
|
||||
def retrieve_position(self):
|
||||
"""
|
||||
Playbooks only have path as position
|
||||
:return:
|
||||
"""
|
||||
# Since the playbook is the whole file, the set the position as the beginning of the file
|
||||
self.path = os.path.join(os.getcwd(), self.name)
|
||||
self.line = 1
|
||||
self.column = 1
|
||||
|
||||
@property
|
||||
def plays(self) -> List['EdgeNode']:
|
||||
"""
|
||||
|
@ -252,6 +277,12 @@ class TaskNode(Node):
|
|||
"""
|
||||
|
||||
def __init__(self, node_name: str, node_id: str = None, raw_object=None):
|
||||
"""
|
||||
|
||||
:param node_name:
|
||||
:param node_id:
|
||||
:param raw_object:
|
||||
"""
|
||||
super().__init__(node_name, node_id or generate_id("task_"), raw_object)
|
||||
|
||||
|
||||
|
@ -260,8 +291,19 @@ class RoleNode(CompositeTasksNode):
|
|||
A role node. A role is a composition of tasks
|
||||
"""
|
||||
|
||||
def __init__(self, node_name: str, node_id: str = None, raw_object=None):
|
||||
def __init__(self, node_name: str, node_id: str = None, raw_object=None, include_role: bool = False):
|
||||
"""
|
||||
|
||||
:param node_name:
|
||||
:param node_id:
|
||||
:param raw_object:
|
||||
"""
|
||||
super().__init__(node_name, node_id or generate_id("role_"), raw_object=raw_object)
|
||||
self.include_role = include_role
|
||||
if raw_object and not include_role:
|
||||
# If it's not an include_role, we take the role path which the path to the folder where the role is located
|
||||
# on the disk
|
||||
self.path = raw_object._role_path
|
||||
|
||||
|
||||
def _get_all_tasks_nodes(composite: CompositeNode, task_acc: List[TaskNode]):
|
||||
|
|
|
@ -95,8 +95,8 @@ class BaseParser(ABC):
|
|||
task_name = clean_name(self.template(task.get_name(), task_vars))
|
||||
edge_label = convert_when_to_str(task.when)
|
||||
|
||||
edge_node = EdgeNode(parent_node, TaskNode(task_name, generate_id(f"{node_type}_"), raw_object=task),
|
||||
edge_label)
|
||||
edge_node = EdgeNode(source=parent_node, node_name=edge_label,
|
||||
destination=TaskNode(task_name, generate_id(f"{node_type}_"), raw_object=task))
|
||||
parent_node.add_node(target_composition=f"{node_type}s", node=edge_node)
|
||||
|
||||
return True
|
||||
|
@ -120,9 +120,6 @@ class PlaybookParser(BaseParser):
|
|||
|
||||
self.include_role_tasks = include_role_tasks
|
||||
self.playbook_filename = playbook_filename
|
||||
self.playbook = None
|
||||
# the root node
|
||||
self.playbook_root_node = None
|
||||
|
||||
def parse(self, *args, **kwargs) -> PlaybookNode:
|
||||
"""
|
||||
|
@ -138,17 +135,19 @@ class PlaybookParser(BaseParser):
|
|||
add post_tasks
|
||||
:return:
|
||||
"""
|
||||
self.playbook = Playbook.load(self.playbook_filename, loader=self.data_loader,
|
||||
variable_manager=self.variable_manager)
|
||||
self.playbook_root_node = PlaybookNode(self.playbook_filename)
|
||||
|
||||
playbook = Playbook.load(self.playbook_filename, loader=self.data_loader,
|
||||
variable_manager=self.variable_manager)
|
||||
# the root node
|
||||
playbook_root_node = PlaybookNode(self.playbook_filename, raw_object=playbook)
|
||||
# loop through the plays
|
||||
for play in self.playbook.get_plays():
|
||||
for play in playbook.get_plays():
|
||||
|
||||
# the load basedir is relative to the playbook path
|
||||
if play._included_path is not None:
|
||||
self.data_loader.set_basedir(play._included_path)
|
||||
else:
|
||||
self.data_loader.set_basedir(self.playbook._basedir)
|
||||
self.data_loader.set_basedir(playbook._basedir)
|
||||
display.vvv(f"Loader basedir set to {self.data_loader.get_basedir()}")
|
||||
|
||||
play_vars = self.variable_manager.get_vars(play)
|
||||
|
@ -159,7 +158,7 @@ class PlaybookParser(BaseParser):
|
|||
display.banner("Parsing " + play_name)
|
||||
|
||||
play_node = PlayNode(play_name, hosts=play_hosts, raw_object=play)
|
||||
self.playbook_root_node.add_play(play_node, "")
|
||||
playbook_root_node.add_play(play_node, "")
|
||||
|
||||
# loop through the pre_tasks
|
||||
display.v("Parsing pre_tasks...")
|
||||
|
@ -182,7 +181,7 @@ class PlaybookParser(BaseParser):
|
|||
# Go to the next role
|
||||
continue
|
||||
|
||||
role_node = RoleNode(clean_name(role.get_name()))
|
||||
role_node = RoleNode(clean_name(role.get_name()), raw_object=role)
|
||||
# edge from play to role
|
||||
play_node.add_node("roles", EdgeNode(play_node, role_node))
|
||||
|
||||
|
@ -215,7 +214,7 @@ class PlaybookParser(BaseParser):
|
|||
display.display("") # just an empty line
|
||||
# moving to the next play
|
||||
|
||||
return self.playbook_root_node
|
||||
return playbook_root_node
|
||||
|
||||
def _include_tasks_in_blocks(self, current_play: Play, parent_nodes: List[CompositeNode],
|
||||
block: Union[Block, TaskInclude], node_type: str, play_vars: Dict = None):
|
||||
|
@ -258,7 +257,7 @@ class PlaybookParser(BaseParser):
|
|||
# See :func:`~ansible.playbook.included_file.IncludedFile.process_include_results` from line 155
|
||||
display.v(f"An 'include_role' found. Including tasks from '{task_or_block.get_name()}'")
|
||||
|
||||
role_node = RoleNode(task_or_block.get_name(), raw_object=task_or_block)
|
||||
role_node = RoleNode(task_or_block.get_name(), raw_object=task_or_block, include_role=True)
|
||||
parent_nodes[-1].add_node(f"{node_type}s", EdgeNode(parent_nodes[-1], role_node,
|
||||
convert_when_to_str(task_or_block.when)))
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ from lxml import etree
|
|||
|
||||
from ansibleplaybookgrapher.graph import PlaybookNode
|
||||
|
||||
JQUERY = 'https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js'
|
||||
JQUERY = 'https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js'
|
||||
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
||||
|
||||
|
||||
|
@ -151,8 +151,8 @@ class GraphVizPostProcessor:
|
|||
text_path.text = text_element.text
|
||||
text_element.append(text_path)
|
||||
|
||||
# Move a little bit the text
|
||||
text_element.set("dy", "-1%")
|
||||
# Move a little the text
|
||||
text_element.set("dy", "-0.5%")
|
||||
# Remove unnecessary attributes
|
||||
text_element.attrib.pop("x")
|
||||
text_element.attrib.pop("y")
|
||||
|
|
|
@ -13,16 +13,52 @@
|
|||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import os
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ansible.utils.display import Display
|
||||
from graphviz import Digraph
|
||||
|
||||
from ansibleplaybookgrapher.graph import PlaybookNode, EdgeNode, PlayNode, RoleNode, BlockNode
|
||||
from ansibleplaybookgrapher.graph import PlaybookNode, EdgeNode, PlayNode, RoleNode, BlockNode, Node
|
||||
from ansibleplaybookgrapher.utils import get_play_colors
|
||||
|
||||
display = Display()
|
||||
|
||||
# The supported protocol handlers to open roles and tasks from the viewer
|
||||
OPEN_PROTOCOL_HANDLERS = {
|
||||
"default": {
|
||||
"folder": "{path}",
|
||||
"file": "{path}"
|
||||
},
|
||||
# https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls
|
||||
"vscode": {
|
||||
"folder": "vscode://file/{path}",
|
||||
"file": "vscode://file/{path}:{line}:{column}"
|
||||
},
|
||||
# For custom, the formats need to be provided
|
||||
"custom": {}
|
||||
}
|
||||
|
||||
|
||||
def get_node_url(protocol_handler: str, node_type: str, node: Node,
|
||||
open_protocol_custom_formats: Dict = None) -> Optional[str]:
|
||||
"""
|
||||
Get the node url based on the chosen protocol
|
||||
:param node_type: task or role
|
||||
:param protocol_handler: the protocol handler to use
|
||||
:param node: the node to get the url for
|
||||
:param open_protocol_custom_formats: the custom formats to use when protocol handler is custom
|
||||
:return:
|
||||
"""
|
||||
# Merge the provided custom formats with the default formats
|
||||
all_formats = {**OPEN_PROTOCOL_HANDLERS, **{"custom": open_protocol_custom_formats}}
|
||||
if node.path:
|
||||
url = all_formats[protocol_handler][node_type].format(path=node.path, line=node.line,
|
||||
column=node.column)
|
||||
display.vvvv(f"Open protocol URL for node {node}: {url}")
|
||||
return url
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class GraphvizRenderer:
|
||||
"""
|
||||
|
@ -31,16 +67,20 @@ class GraphvizRenderer:
|
|||
DEFAULT_EDGE_ATTR = {"sep": "10", "esep": "5"}
|
||||
DEFAULT_GRAPH_ATTR = {"ratio": "fill", "rankdir": "LR", "concentrate": "true", "ordering": "in"}
|
||||
|
||||
def __init__(self, playbook_node: 'PlaybookNode', graph_format: str = "svg",
|
||||
graph_attr: Dict = None, edge_attr: Dict = None):
|
||||
def __init__(self, playbook_node: 'PlaybookNode', open_protocol_handler: str,
|
||||
open_protocol_custom_formats: Dict = None, graph_format: str = "svg", graph_attr: Dict = None,
|
||||
edge_attr: Dict = None):
|
||||
"""
|
||||
|
||||
:param playbook_node: Playbook parsed node
|
||||
:param open_protocol_handler: The protocol handler name to use. See OPEN_PROTOCOL_HANDLERS
|
||||
:param graph_format: the graph format to render. See https://graphviz.org/docs/outputs/
|
||||
:param graph_attr: Default graph attributes
|
||||
:param edge_attr: Default edge attributes
|
||||
"""
|
||||
self.playbook_node = playbook_node
|
||||
self.open_protocol_handler = open_protocol_handler
|
||||
self.open_protocol_custom_formats = open_protocol_custom_formats
|
||||
self.digraph = Digraph(format=graph_format,
|
||||
graph_attr=graph_attr or GraphvizRenderer.DEFAULT_GRAPH_ATTR,
|
||||
edge_attr=edge_attr or GraphvizRenderer.DEFAULT_EDGE_ATTR)
|
||||
|
@ -67,7 +107,9 @@ class GraphvizRenderer:
|
|||
else:
|
||||
edge_label = f"{node_counter} {edge.name}"
|
||||
graph.node(destination_node.id, label=node_label_prefix + destination_node.name, shape=shape,
|
||||
id=destination_node.id, tooltip=destination_node.name, color=color)
|
||||
id=destination_node.id, tooltip=destination_node.name, color=color,
|
||||
URL=get_node_url(self.open_protocol_handler, "file", destination_node,
|
||||
self.open_protocol_custom_formats))
|
||||
graph.edge(source_node.id, destination_node.id, label=edge_label, color=color, fontcolor=color, id=edge.id,
|
||||
tooltip=edge_label, labeltooltip=edge_label)
|
||||
|
||||
|
@ -91,7 +133,9 @@ class GraphvizRenderer:
|
|||
with graph.subgraph(name=f"cluster_{destination_node.id}") as block_subgraph:
|
||||
block_subgraph.node(destination_node.id, label=f"[block] {destination_node.name}", shape="box",
|
||||
id=destination_node.id, tooltip=destination_node.name, color=color,
|
||||
labeltooltip=destination_node.name)
|
||||
labeltooltip=destination_node.name,
|
||||
URL=get_node_url(self.open_protocol_handler, "file", destination_node,
|
||||
self.open_protocol_custom_formats))
|
||||
graph.edge(edge.source.id, destination_node.id, label=edge_label, color=color, fontcolor=color,
|
||||
tooltip=edge_label, id=edge.id, labeltooltip=edge_label)
|
||||
|
||||
|
@ -114,8 +158,14 @@ class GraphvizRenderer:
|
|||
role = edge.destination # type: RoleNode
|
||||
role_edge_label = f"{edge_counter} {edge.name}"
|
||||
|
||||
if role.include_role: # For include_role, we point to a file
|
||||
url = get_node_url(self.open_protocol_handler, "file", role, self.open_protocol_custom_formats)
|
||||
else: # For normal role invocation, we point to the folder
|
||||
url = get_node_url(self.open_protocol_handler, "folder", role, self.open_protocol_custom_formats)
|
||||
|
||||
with self.digraph.subgraph(name=role.name, node_attr={}) as role_subgraph:
|
||||
role_subgraph.node(role.id, id=role.id, label=f"[role] {role.name}", tooltip=role.name, color=color)
|
||||
role_subgraph.node(role.id, id=role.id, label=f"[role] {role.name}", tooltip=role.name, color=color,
|
||||
URL=url)
|
||||
# from parent to role
|
||||
graph.edge(edge.source.id, role.id, label=role_edge_label, color=color, fontcolor=color, id=edge.id,
|
||||
tooltip=role_edge_label, labeltooltip=role_edge_label)
|
||||
|
@ -131,7 +181,9 @@ class GraphvizRenderer:
|
|||
"""
|
||||
display.vvv(f"Converting the graph to the dot format for graphviz")
|
||||
# root node
|
||||
self.digraph.node(self.playbook_node.name, style="dotted", id="root_node")
|
||||
self.digraph.node(self.playbook_node.name, style="dotted", id=self.playbook_node.id,
|
||||
URL=get_node_url(self.open_protocol_handler, "file", self.playbook_node,
|
||||
self.open_protocol_custom_formats))
|
||||
|
||||
for play_counter, play_edge in enumerate(self.playbook_node.plays, 1):
|
||||
# noinspection PyTypeChecker
|
||||
|
@ -141,7 +193,9 @@ class GraphvizRenderer:
|
|||
# play node
|
||||
play_tooltip = ",".join(play.hosts) if len(play.hosts) > 0 else play.name
|
||||
self.digraph.node(play.id, id=play.id, label=play.name, style="filled", shape="box", color=color,
|
||||
fontcolor=play_font_color, tooltip=play_tooltip)
|
||||
fontcolor=play_font_color, tooltip=play_tooltip,
|
||||
URL=get_node_url(self.open_protocol_handler, "file", play,
|
||||
self.open_protocol_custom_formats))
|
||||
# edge from root node to play
|
||||
playbook_to_play_label = f"{play_counter} {play_edge.name}"
|
||||
self.digraph.edge(self.playbook_node.name, play.id, id=play_edge.id, label=playbook_to_play_label,
|
||||
|
|
2
tests/fixtures/import_role.yml
vendored
2
tests/fixtures/import_role.yml
vendored
|
@ -13,4 +13,4 @@
|
|||
msg: Hello world
|
||||
- name: Import role
|
||||
import_role:
|
||||
name: display_some_facts
|
||||
name: display_some_facts
|
||||
|
|
3
tests/fixtures/with_roles.yml
vendored
3
tests/fixtures/with_roles.yml
vendored
|
@ -23,7 +23,7 @@
|
|||
when: ansible_distribution == "Debian"
|
||||
tags:
|
||||
- role_tag
|
||||
|
||||
- role: display_some_facts
|
||||
tasks:
|
||||
- name: Add backport {{backport}}
|
||||
become: yes
|
||||
|
@ -32,7 +32,6 @@
|
|||
filename: "{{backport}}"
|
||||
state: present
|
||||
update_cache: yes
|
||||
|
||||
- name: Install packages
|
||||
become: yes
|
||||
apt:
|
||||
|
|
|
@ -128,7 +128,7 @@ def test_cli_tags(tags_option, expected):
|
|||
def test_skip_tags(skip_tags_option, expected):
|
||||
"""
|
||||
|
||||
:param tags_option:
|
||||
:param skip_tags_option:
|
||||
:param expected:
|
||||
:return:
|
||||
"""
|
||||
|
@ -181,3 +181,50 @@ def test_cli_verbosity_options(verbosity, verbosity_number):
|
|||
cli.parse()
|
||||
|
||||
assert cli.options.verbosity == verbosity_number
|
||||
|
||||
|
||||
def test_cli_open_protocol_custom_formats():
|
||||
"""
|
||||
The provided format should be converted to a dict
|
||||
:return:
|
||||
"""
|
||||
formats_str = '{"file": "{path}", "folder": "{path}"}'
|
||||
args = [__prog__, '--open-protocol-handler', 'custom', '--open-protocol-custom-formats', formats_str,
|
||||
'playbook1.yml']
|
||||
|
||||
cli = get_cli_class()(args)
|
||||
cli.parse()
|
||||
assert cli.options.open_protocol_custom_formats == {"file": "{path}",
|
||||
"folder": "{path}"}, "The formats should be converted to json"
|
||||
|
||||
|
||||
def test_cli_open_protocol_custom_formats_not_provided():
|
||||
"""
|
||||
The custom formats must be provided when the protocol handler is set to custom
|
||||
:return:
|
||||
"""
|
||||
args = [__prog__, '--open-protocol-handler', 'custom', 'playbook1.yml']
|
||||
|
||||
cli = get_cli_class()(args)
|
||||
with pytest.raises(AnsibleOptionsError) as exception_info:
|
||||
cli.parse()
|
||||
|
||||
assert "you must provide the formats to use with --open-protocol-custom-formats" in exception_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("formats, expected_message",
|
||||
[['invalid_json', "JSONDecodeError"],
|
||||
['{}', "The field 'file' or 'folder' is missing"]])
|
||||
def test_cli_open_protocol_custom_formats_invalid_inputs(formats, expected_message, capsys):
|
||||
"""
|
||||
The custom formats must be a valid json data
|
||||
:return:
|
||||
"""
|
||||
args = [__prog__, '--open-protocol-handler', 'custom', '--open-protocol-custom-formats', formats, 'playbook1.yml']
|
||||
|
||||
cli = get_cli_class()(args)
|
||||
with pytest.raises(SystemExit) as exception_info:
|
||||
cli.parse()
|
||||
|
||||
error_msg = capsys.readouterr().err
|
||||
assert expected_message in error_msg
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import os
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
@ -6,11 +7,17 @@ from ansible.utils.display import Display
|
|||
from ansibleplaybookgrapher import PlaybookParser
|
||||
from ansibleplaybookgrapher.cli import PlaybookGrapherCLI
|
||||
from ansibleplaybookgrapher.graph import TaskNode, BlockNode, RoleNode, get_all_tasks_nodes, CompositeNode
|
||||
from tests import FIXTURES_DIR
|
||||
|
||||
# This file directory abspath
|
||||
DIR_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||
# Fixtures abspath
|
||||
FIXTURES_PATH = os.path.join(DIR_PATH, FIXTURES_DIR)
|
||||
|
||||
|
||||
def get_all_tasks(composites: List[CompositeNode]) -> List[TaskNode]:
|
||||
"""
|
||||
|
||||
Get all tasks from a list of composite nodes
|
||||
:param composites:
|
||||
:return:
|
||||
"""
|
||||
|
@ -22,12 +29,59 @@ def get_all_tasks(composites: List[CompositeNode]) -> List[TaskNode]:
|
|||
return tasks
|
||||
|
||||
|
||||
@pytest.mark.parametrize('grapher_cli', [["example.yml"]], indirect=True)
|
||||
def test_example_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
|
||||
"""
|
||||
Test the parsing of example.yml
|
||||
:param grapher_cli:
|
||||
:param display:
|
||||
:return:
|
||||
"""
|
||||
parser = PlaybookParser(grapher_cli.options.playbook_filename)
|
||||
playbook_node = parser.parse()
|
||||
assert len(playbook_node.plays) == 1
|
||||
assert playbook_node.path == os.path.join(FIXTURES_PATH, "example.yml")
|
||||
assert playbook_node.line == 1
|
||||
assert playbook_node.column == 1
|
||||
|
||||
play_node = playbook_node.plays[0].destination
|
||||
assert play_node.path == os.path.join(FIXTURES_PATH, "example.yml")
|
||||
assert play_node.line == 2
|
||||
|
||||
pre_tasks = play_node.pre_tasks
|
||||
tasks = play_node.tasks
|
||||
post_tasks = play_node.post_tasks
|
||||
assert len(pre_tasks) == 2
|
||||
assert len(tasks) == 4
|
||||
assert len(post_tasks) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('grapher_cli', [["with_roles.yml"]], indirect=True)
|
||||
def test_with_roles_parsing(grapher_cli: PlaybookGrapherCLI):
|
||||
"""
|
||||
Test the parsing of with_roles.yml
|
||||
:param grapher_cli:
|
||||
:return:
|
||||
"""
|
||||
parser = PlaybookParser(grapher_cli.options.playbook_filename)
|
||||
playbook_node = parser.parse()
|
||||
assert len(playbook_node.plays) == 1
|
||||
play_node = playbook_node.plays[0].destination
|
||||
assert len(play_node.roles) == 2
|
||||
|
||||
fake_role = play_node.roles[0].destination
|
||||
assert isinstance(fake_role, RoleNode)
|
||||
assert not fake_role.include_role
|
||||
assert fake_role.path == os.path.join(FIXTURES_PATH, "roles", "fake_role")
|
||||
assert fake_role.line is None
|
||||
assert fake_role.column is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('grapher_cli', [["include_role.yml"]], indirect=True)
|
||||
def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display, capsys):
|
||||
def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, capsys):
|
||||
"""
|
||||
Test parsing of include_role
|
||||
:param grapher_cli:
|
||||
:param display:
|
||||
:return:
|
||||
"""
|
||||
parser = PlaybookParser(grapher_cli.options.playbook_filename, include_role_tasks=True)
|
||||
|
@ -44,6 +98,9 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display,
|
|||
# first include_role
|
||||
include_role_1 = tasks[0].destination
|
||||
assert isinstance(include_role_1, RoleNode)
|
||||
assert include_role_1.include_role
|
||||
assert include_role_1.path == os.path.join(FIXTURES_PATH, "include_role.yml")
|
||||
assert include_role_1.line == 6
|
||||
assert len(include_role_1.tasks) == 0, "We don't support adding tasks from include_role with loop"
|
||||
|
||||
# first task
|
||||
|
@ -53,6 +110,7 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display,
|
|||
# second include_role
|
||||
include_role_2 = tasks[2].destination
|
||||
assert isinstance(include_role_2, RoleNode)
|
||||
assert include_role_2.include_role
|
||||
assert len(include_role_2.tasks) == 3
|
||||
|
||||
# second task
|
||||
|
@ -62,20 +120,21 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display,
|
|||
include_role_3 = tasks[4].destination
|
||||
assert tasks[4].name == "[when: x is not defined]"
|
||||
assert isinstance(include_role_3, RoleNode)
|
||||
assert include_role_3.include_role
|
||||
assert len(include_role_3.tasks) == 3
|
||||
|
||||
# fourth include_role
|
||||
include_role_4 = tasks[5].destination
|
||||
assert isinstance(include_role_4, RoleNode)
|
||||
assert include_role_4.include_role
|
||||
assert len(include_role_4.tasks) == 0, "We don't support adding tasks from include_role with loop"
|
||||
|
||||
|
||||
@pytest.mark.parametrize('grapher_cli', [["with_block.yml"]], indirect=True)
|
||||
def test_block_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
|
||||
def test_block_parsing(grapher_cli: PlaybookGrapherCLI):
|
||||
"""
|
||||
The parsing of a playbook with blocks
|
||||
:param grapher_cli:
|
||||
:param display:
|
||||
:return:
|
||||
"""
|
||||
parser = PlaybookParser(grapher_cli.options.playbook_filename, include_role_tasks=True)
|
||||
|
@ -97,7 +156,10 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
|
|||
|
||||
# Check pre tasks
|
||||
assert isinstance(pre_tasks[0].destination, RoleNode), "The first edge should have a RoleNode as destination"
|
||||
assert isinstance(pre_tasks[1].destination, BlockNode), "The second edge should have a BlockNode as destination"
|
||||
pre_task_block = pre_tasks[1].destination
|
||||
assert isinstance(pre_task_block, BlockNode), "The second edge should have a BlockNode as destination"
|
||||
assert pre_task_block.path == os.path.join(FIXTURES_PATH, "with_block.yml")
|
||||
assert pre_task_block.line == 7
|
||||
|
||||
# Check tasks
|
||||
task_1 = tasks[0].destination
|
||||
|
@ -112,6 +174,7 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
|
|||
|
||||
# Check the second block (nested block)
|
||||
nested_block = first_block.tasks[2].destination
|
||||
assert isinstance(nested_block, BlockNode)
|
||||
assert len(nested_block.tasks) == 2
|
||||
assert nested_block.tasks[0].destination.name == "get_url"
|
||||
assert nested_block.tasks[1].destination.name == "command"
|
||||
|
|
|
@ -9,6 +9,9 @@ from ansibleplaybookgrapher import __prog__
|
|||
from ansibleplaybookgrapher.cli import get_cli_class
|
||||
from tests import FIXTURES_DIR
|
||||
|
||||
# This file directory abspath
|
||||
DIR_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
def run_grapher(playbook_file: str, output_filename: str = None, additional_args: List[str] = None) -> Tuple[str, str]:
|
||||
"""
|
||||
|
@ -23,7 +26,11 @@ def run_grapher(playbook_file: str, output_filename: str = None, additional_args
|
|||
additional_args.insert(0, "-vv")
|
||||
|
||||
if os.environ.get("TEST_VIEW_GENERATED_FILE") == "1":
|
||||
additional_args.insert(1, "--view")
|
||||
additional_args.insert(0, "--view")
|
||||
|
||||
if "--open-protocol-handler" not in additional_args:
|
||||
additional_args.insert(0, "--open-protocol-handler")
|
||||
additional_args.insert(1, "vscode")
|
||||
|
||||
playbook_path = os.path.join(FIXTURES_DIR, playbook_file)
|
||||
args = [__prog__]
|
||||
|
@ -31,8 +38,7 @@ def run_grapher(playbook_file: str, output_filename: str = None, additional_args
|
|||
if output_filename: # the default filename is the playbook file name minus .yml
|
||||
# put the generated svg in a dedicated folder
|
||||
output_filename = output_filename.replace("[", "-").replace("]", "")
|
||||
dir_path = os.path.dirname(os.path.realpath(__file__)) # current file directory
|
||||
args.extend(['-o', os.path.join(dir_path, "generated_svg", output_filename)])
|
||||
args.extend(['-o', os.path.join(DIR_PATH, "generated_svg", output_filename)])
|
||||
|
||||
args.extend(additional_args)
|
||||
|
||||
|
@ -64,7 +70,7 @@ def _common_tests(svg_path: str, playbook_path: str, plays_number: int = 0, task
|
|||
|
||||
# test if the file exist. It will exist only if we write in it.
|
||||
assert os.path.isfile(svg_path), "The svg file should exist"
|
||||
assert pq('#root_node text').text() == playbook_path
|
||||
assert pq('g[id^=playbook_] text').text() == playbook_path
|
||||
|
||||
plays = pq("g[id^='play_']")
|
||||
tasks = pq("g[id^='task_']")
|
||||
|
@ -133,7 +139,7 @@ def test_import_tasks(request):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(["include_role_tasks_option", "expected_tasks_number"],
|
||||
[("--", 2), ("--include-role-tasks", 5)],
|
||||
[("--", 2), ("--include-role-tasks", 8)],
|
||||
ids=["no_include_role_tasks_option", "include_role_tasks_option"])
|
||||
def test_with_roles(request, include_role_tasks_option, expected_tasks_number):
|
||||
"""
|
||||
|
@ -144,7 +150,7 @@ def test_with_roles(request, include_role_tasks_option, expected_tasks_number):
|
|||
additional_args=[include_role_tasks_option])
|
||||
|
||||
_common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, tasks_number=expected_tasks_number,
|
||||
post_tasks_number=2, pre_tasks_number=2, roles_number=1)
|
||||
post_tasks_number=2, pre_tasks_number=2, roles_number=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(["include_role_tasks_option", "expected_tasks_number"],
|
||||
|
@ -163,7 +169,7 @@ def test_include_role(request, include_role_tasks_option, expected_tasks_number)
|
|||
|
||||
def test_with_block(request):
|
||||
"""
|
||||
Test with_roles.yml, an example with roles
|
||||
Test with_block.yml, an example with roles
|
||||
"""
|
||||
svg_path, playbook_path = run_grapher("with_block.yml", output_filename=request.node.name,
|
||||
additional_args=["--include-role-tasks"])
|
||||
|
@ -247,3 +253,24 @@ def test_skip_tags(request):
|
|||
additional_args=["--skip-tags", "pre_task_tag_1", "--include-role-tasks"])
|
||||
_common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, pre_tasks_number=1, roles_number=1,
|
||||
tasks_number=3)
|
||||
|
||||
|
||||
def test_with_roles_with_custom_protocol_handlers(request):
|
||||
"""
|
||||
Test with_roles.yml with a custom protocol handlers
|
||||
"""
|
||||
formats_str = '{"file": "vscode://file/{path}:{line}", "folder": "{path}"}'
|
||||
svg_path, playbook_path = run_grapher("with_roles.yml", output_filename=request.node.name,
|
||||
additional_args=["--open-protocol-handler", "custom",
|
||||
"--open-protocol-custom-formats", formats_str])
|
||||
|
||||
res = _common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, tasks_number=2,
|
||||
post_tasks_number=2, pre_tasks_number=2, roles_number=2)
|
||||
|
||||
xlink_ref_selector = "{http://www.w3.org/1999/xlink}href"
|
||||
for t in res["tasks"]:
|
||||
assert t.find("g/a").get(xlink_ref_selector).startswith(
|
||||
f"vscode://file/{DIR_PATH}"), "Tasks should be open with vscode"
|
||||
|
||||
for r in res["roles"]:
|
||||
assert r.find("g/a").get(xlink_ref_selector).startswith(DIR_PATH)
|
||||
|
|
Loading…
Reference in a new issue