# Test tools API.
import contextlib
import json
import os
import zipfile
from io import BytesIO
from typing import (
    Any,
    Optional,
)
from uuid import uuid4

import pytest
from jsonschema import validate
from jsonschema.exceptions import ValidationError
from requests import (
    get,
    put,
)

from galaxy.tool_util.verify.interactor import ValidToolTestDict
from galaxy.util import galaxy_root_path
from galaxy_test.base import rules_test_data
from galaxy_test.base.api_asserts import (
    assert_error_code_is,
    assert_file_looks_like_xlsx,
    assert_has_keys,
    assert_status_code_is,
)
from galaxy_test.base.decorators import requires_new_history
from galaxy_test.base.populators import (
    BaseDatasetCollectionPopulator,
    DatasetCollectionPopulator,
    DatasetPopulator,
    skip_without_tool,
    stage_rules_example,
    TOOL_WITH_SHELL_COMMAND,
)
from ._framework import ApiTestCase

MINIMAL_TOOL = {
    "id": "minimal_tool",
    "name": "Minimal Tool",
    "class": "GalaxyTool",
    "version": "1.0.0",
    "command": "echo 'Hello World' > $output1",
    "inputs": [],
    "outputs": {"output1": {"format": "txt", "type": "data"}},
}
MINIMAL_TOOL_NO_ID = {
    "name": "Minimal Tool",
    "class": "GalaxyTool",
    "version": "1.0.0",
    "command": "echo 'Hello World 2' > $output1",
    "inputs": [],
    "outputs": {"output1": {"format": "txt", "type": "data"}},
}


class TestsTools:
    dataset_populator: DatasetPopulator
    dataset_collection_populator: BaseDatasetCollectionPopulator

    def _build_pair(self, history_id, contents):
        create_response = self.dataset_collection_populator.create_pair_in_history(
            history_id,
            contents=contents,
            direct_upload=True,
            wait=True,
        )
        hdca_id = create_response.json()["outputs"][0]["id"]
        return hdca_id

    def _get_build_option_values(self, build, input_name):
        matching = [i for i in build["inputs"] if i["name"] == input_name][0]
        return [o[1] for o in matching["options"]]

    def _run_cat(self, history_id, inputs, assert_ok=False, **kwargs):
        return self._run("cat", history_id, inputs, assert_ok=assert_ok, **kwargs)

    def _run(
        self,
        tool_id=None,
        history_id=None,
        inputs=None,
        tool_uuid=None,
        assert_ok=False,
        tool_version=None,
        use_cached_job=False,
        wait_for_job=False,
        input_format="legacy",
    ):
        if inputs is None:
            inputs = {}
        if tool_id is None:
            assert tool_uuid is not None
        payload = self.dataset_populator.run_tool_payload(
            tool_id=tool_id,
            inputs=inputs,
            history_id=history_id,
            input_format=input_format,
        )
        if tool_uuid:
            payload["tool_uuid"] = tool_uuid
        if tool_version is not None:
            payload["tool_version"] = tool_version
        if use_cached_job:
            payload["use_cached_job"] = True
        create_response = self.dataset_populator._post("tools", data=payload)
        if wait_for_job:
            self.dataset_populator.wait_for_job(job_id=create_response.json()["jobs"][0]["id"])
        if assert_ok:
            assert_status_code_is(create_response, 200)
            create = create_response.json()
            assert_has_keys(create, "outputs")
            return create
        else:
            return create_response


class TestToolsApi(ApiTestCase, TestsTools):
    dataset_populator: DatasetPopulator

    def setUp(self):
        super().setUp()
        self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
        self.dataset_collection_populator = DatasetCollectionPopulator(self.galaxy_interactor)

    def test_index(self):
        tool_ids = self.__tool_ids()
        assert "upload1" in tool_ids

    @skip_without_tool("cat1")
    def test_search_cat(self):
        url = self._api_url("tools")
        payload = dict(q="concat")
        get_response = get(url, payload).json()
        assert "cat1" in get_response

    @skip_without_tool("trimmer")
    def test_search_trimmer(self):
        url = self._api_url("tools")
        payload = dict(q="leading or trailing characters")
        get_response = get(url, payload).json()
        assert "trimmer" in get_response

    @skip_without_tool("Grep1")
    def test_search_grep(self):
        url = self._api_url("tools")
        payload = dict(q="Select lines that match an expression")
        get_response = get(url, payload).json()
        assert "Grep1" in get_response

    def test_no_panel_index(self):
        index = self._get("tools", data=dict(in_panel=False))
        tools_index = index.json()
        # No need to flatten out sections, with in_panel=False, only tools are
        # returned.
        tool_ids = [_["id"] for _ in tools_index]
        assert "upload1" in tool_ids

    @skip_without_tool("test_sam_to_bam_conversions")
    def test_requirements(self):
        requirements_response = self._get("tools/test_sam_to_bam_conversions/requirements", admin=True)
        self._assert_status_code_is_ok(requirements_response)
        requirements = requirements_response.json()
        assert len(requirements) == 1, requirements
        requirement = requirements[0]
        assert requirement["name"] == "samtools", requirement

    @skip_without_tool("cat1")
    def test_show_repeat(self):
        tool_info = self._show_valid_tool("cat1")
        parameters = tool_info["inputs"]
        assert len(parameters) == 2, f"Expected two inputs - got [{parameters}]"
        assert parameters[0]["name"] == "input1"
        assert parameters[1]["name"] == "queries"

        repeat_info = parameters[1]
        self._assert_has_keys(repeat_info, "min", "max", "title", "help")
        repeat_params = repeat_info["inputs"]
        assert len(repeat_params) == 1
        assert repeat_params[0]["name"] == "input2"

    @skip_without_tool("random_lines1")
    def test_show_conditional(self):
        tool_info = self._show_valid_tool("random_lines1")

        cond_info = tool_info["inputs"][2]
        self._assert_has_keys(cond_info, "cases", "test_param")
        self._assert_has_keys(cond_info["test_param"], "name", "type", "label", "help")

        cases = cond_info["cases"]
        assert len(cases) == 2
        case1 = cases[0]
        self._assert_has_keys(case1, "value", "inputs")
        assert case1["value"] == "no_seed"
        assert len(case1["inputs"]) == 0

        case2 = cases[1]
        self._assert_has_keys(case2, "value", "inputs")
        case2_inputs = case2["inputs"]
        assert len(case2_inputs) == 1
        self._assert_has_keys(case2_inputs[0], "name", "type", "label", "help", "argument")
        assert case2_inputs[0]["name"] == "seed"

    @skip_without_tool("gx_conditional_select")
    def test_invalid_conditional_payload_handled(self):
        with self.dataset_populator.test_history() as history_id:
            # Invalid request, should be `{"conditional_parameter": {"test_parameter": "A"}}`
            response = self._run(
                tool_id="gx_conditional_select", history_id=history_id, inputs={"conditional_parameter": "A"}
            )
            assert response.status_code == 400
            assert (
                response.json()["err_msg"]
                == "Invalid value 'A' submitted for conditional parameter 'conditional_parameter'."
            )

    @skip_without_tool("multi_data_param")
    def test_show_multi_data(self):
        tool_info = self._show_valid_tool("multi_data_param")

        f1_info, f2_info = tool_info["inputs"][0], tool_info["inputs"][1]
        self._assert_has_keys(f1_info, "min", "max")
        assert f1_info["min"] == 1
        assert f1_info["max"] == 1235

        self._assert_has_keys(f2_info, "min", "max")
        assert f2_info["min"] is None
        assert f2_info["max"] is None

    @skip_without_tool("collection_creates_list")
    def test_show_output_collection(self):
        tool_info = self._show_valid_tool("collection_creates_list")

        outputs = tool_info["outputs"]
        assert len(outputs) == 1
        output = outputs[0]
        assert output["label"] == "Duplicate List"
        assert output["inherit_format"] is True

    def test_tool_icon_endpoint_with_simple_id(self):
        response = self._get("tools/simple_tool_id/icon")
        self._assert_status_code_is(response, 404)

    def test_tool_icon_endpoint_with_toolshed_id(self):
        # Test complex toolshed tool ID with slashes
        toolshed_tool_id = "toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc/0.74+galaxy0"
        response = self._get(f"tools/{toolshed_tool_id}/icon")
        # We expect either 200 (if tool is installed and has icon) or 404 (if
        # not found), but this tests the routing either way.
        assert response.status_code in [200, 404]
        if response.status_code == 200:
            assert response.headers["Content-Type"] == "image/png"

    @skip_without_tool("test_data_source")
    def test_data_source_build_request(self):
        with self.dataset_populator.test_history() as history_id:
            build = self.dataset_populator.build_tool_state("test_data_source", history_id)
            galaxy_url_param = build["inputs"][0]
            assert galaxy_url_param["name"] == "GALAXY_URL"
            galaxy_url = galaxy_url_param["value"]
            assert galaxy_url.startswith("http")
            assert galaxy_url.endswith("tool_runner?tool_id=ratmine")

    @skip_without_tool("dbkey_filter_input")
    def test_build_request_dbkey_filter_set(self):
        with self.dataset_populator.test_history() as history_id:
            hda = self.dataset_populator.new_dataset(history_id, content="test", dbkey="hg19", wait=True)
            inputs = {"inputs": {"src": "hda", "id": hda["id"]}}
            build = self.dataset_populator.build_tool_state("dbkey_filter_input", history_id, inputs=inputs)
            option_values = self._get_build_option_values(build, "index")
            assert "hg19_value" in option_values
            assert "hg18_value" not in option_values

    @skip_without_tool("dbkey_filter_input")
    def test_build_request_dbkey_filter_unset(self):
        with self.dataset_populator.test_history() as history_id:
            hda = self.dataset_populator.new_dataset(history_id, content="test", wait=True)
            inputs = {"inputs": {"src": "hda", "id": hda["id"]}}
            build = self.dataset_populator.build_tool_state("dbkey_filter_input", history_id, inputs=inputs)
            option_values = self._get_build_option_values(build, "index")
            # with no dbkey set, all options from test_fasta_indexes should be available
            assert "hg19_value" in option_values
            assert "hg18_value" in option_values
            assert "mm10_value" in option_values

    @skip_without_tool("cheetah_problem_unbound_var_input")
    def test_legacy_biotools_xref_injection(self):
        url = self._api_url("tools/cheetah_problem_unbound_var_input")
        get_response = get(url)
        get_response.raise_for_status()
        get_json = get_response.json()
        assert "xrefs" in get_json
        assert len(get_json["xrefs"]) == 1
        xref = get_json["xrefs"][0]
        assert xref["type"] == "bio.tools"
        assert xref["value"] == "bwa"

    @skip_without_tool("gx_int")
    def test_tool_schemas(self):
        tool_id = "gx_int"

        def get_jsonschema(state_type: str):
            schema_url = self._api_url(f"tools/{tool_id}/parameter_{state_type}_schema")
            schema_response = get(schema_url)
            schema_response.raise_for_status()
            return schema_response.json()

        request_schema = get_jsonschema("request")
        validate(instance={"parameter": 5}, schema=request_schema)
        with pytest.raises(ValidationError):
            validate(instance={"parameter": "Foobar"}, schema=request_schema)

        test_case_schema = get_jsonschema("test_case_xml")
        validate(instance={"parameter": 5}, schema=test_case_schema)
        with pytest.raises(ValidationError):
            validate(instance={"parameter": "Foobar"}, schema=test_case_schema)

        landing_schema = get_jsonschema("landing_request")
        validate(instance={"parameter": 5}, schema=landing_schema)
        with pytest.raises(ValidationError):
            validate(instance={"parameter": "Foobar"}, schema=landing_schema)

    @skip_without_tool("cat_data_and_sleep")
    def test_tool_schema_metadata(self):
        """Test that tool parameter JSON schemas include Galaxy metadata attributes."""

        # cat_data_and_sleep has an integer param with label and help
        schema = self.dataset_populator.get_request_schema("cat_data_and_sleep")
        props = schema["properties"]
        sleep_prop = props["sleep_time"]
        assert sleep_prop["title"] == "Sleep"
        assert "Optionally simulates computation" in sleep_prop["description"]
        assert sleep_prop["gx_type"] == "gx_integer"

        # data param should have gx_type and gx_extensions
        input1_prop = props["input1"]
        assert input1_prop["title"] == "Concatenate Dataset"
        assert input1_prop["gx_type"] == "gx_data"
        assert "gx_extensions" in input1_prop

        # repeat param should have gx_type
        queries_prop = props["queries"]
        assert queries_prop["gx_type"] == "gx_repeat"

    @skip_without_tool("gx_int_min_max")
    def test_tool_schema_min_max(self):
        schema = self.dataset_populator.get_request_schema("gx_int_min_max")
        param_prop = schema["properties"]["parameter"]
        assert param_prop["gx_type"] == "gx_integer"
        assert param_prop["gx_min"] == 0
        assert param_prop["gx_max"] == 10

    @skip_without_tool("gx_select")
    def test_tool_schema_select_options(self):
        schema = self.dataset_populator.get_request_schema("gx_select")
        param_prop = schema["properties"]["parameter"]
        assert param_prop["gx_type"] == "gx_select"
        assert "gx_options" in param_prop
        option_values = [o["value"] for o in param_prop["gx_options"]]
        assert "--ex1" in option_values
        assert "ex2" in option_values

    @skip_without_tool("test_data_source")
    def test_data_source_ok_request(self, mock_http_server):
        with self.dataset_populator.test_history() as history_id:
            url = mock_http_server.get_url(
                remote_url="https://raw.githubusercontent.com/galaxyproject/galaxy/dev/test-data/1.bed",
                file_path="test-data/1.bed",
            )
            payload = self.dataset_populator.run_tool_payload(
                tool_id="test_data_source",
                inputs={
                    "URL": url,
                    "URL_method": "get",
                    "data_type": "bed",
                },
                history_id=history_id,
            )
            create_response = self._post("tools", data=payload)
            self._assert_status_code_is(create_response, 200)
            create_object = create_response.json()
            self._assert_has_keys(create_object, "outputs")
            assert len(create_object["outputs"]) == 1
            output = create_object["outputs"][0]
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output)
            assert output_content.startswith("chr1\t147962192\t147962580")

            output_details = self.dataset_populator.get_history_dataset_details(history_id, dataset=output)
            assert output_details["file_ext"] == "bed"

    @skip_without_tool("test_data_source")
    def test_data_source_sniff_fastqsanger(self):
        with self.dataset_populator.test_history() as history_id:
            payload = self.dataset_populator.run_tool_payload(
                tool_id="test_data_source",
                inputs={
                    "URL": "https://raw.githubusercontent.com/galaxyproject/galaxy/dev/test-data/1.fastqsanger.gz",
                    "URL_method": "get",
                },
                history_id=history_id,
            )
            create_response = self._post("tools", data=payload)
            self._assert_status_code_is(create_response, 200)
            create_object = create_response.json()
            self._assert_has_keys(create_object, "outputs")
            assert len(create_object["outputs"]) == 1
            output = create_object["outputs"][0]
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            output_details = self.dataset_populator.get_history_dataset_details(history_id, dataset=output)
            assert output_details["file_ext"] == "fastqsanger.gz", output_details

    @skip_without_tool("test_data_source")
    def test_data_sources_block_file_parameters(self):
        with self.dataset_populator.test_history() as history_id:
            payload = self.dataset_populator.run_tool_payload(
                tool_id="test_data_source",
                inputs={
                    "URL": f"file://{os.path.join(os.getcwd(), 'README.rst')}",
                    "URL_method": "get",
                    "data_type": "bed",
                },
                history_id=history_id,
            )
            create_response = self._post("tools", data=payload)
            self._assert_status_code_is(create_response, 200)
            create_object = create_response.json()
            self._assert_has_keys(create_object, "outputs")
            assert len(create_object["outputs"]) == 1
            output = create_object["outputs"][0]
            self.dataset_populator.wait_for_history(history_id, assert_ok=False)

            output_details = self.dataset_populator.get_history_dataset_details(history_id, dataset=output, wait=False)
            assert output_details["state"] == "error", output_details
            assert "has not sent back a URL parameter" in output_details["misc_info"], output_details

    def _show_valid_tool(self, tool_id, tool_version=None):
        data = dict(io_details=True)
        if tool_version:
            data["tool_version"] = tool_version
        tool_show_response = self._get(f"tools/{tool_id}", data=data)
        self._assert_status_code_is(tool_show_response, 200)
        tool_info = tool_show_response.json()
        self._assert_has_keys(tool_info, "inputs", "outputs", "panel_section_id")
        return tool_info

    @skip_without_tool("model_attributes")
    def test_model_attributes_sanitization(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            cool_name_with_quote = 'cool name with a quo"te'
            cool_name_without_quote = "cool name with a quo__dq__te"

            current_user = self._get("users/current").json()
            user_info_url = self._api_url(f"users/{current_user['id']}/information/inputs", use_key=True)
            put_response = put(user_info_url, data=json.dumps({"address_0|desc": cool_name_with_quote}))
            put_response.raise_for_status()

            response = get(user_info_url).json()
            assert len(response["addresses"]) == 1
            assert response["addresses"][0]["desc"] == cool_name_with_quote

            hda1 = self.dataset_populator.new_dataset(history_id, content="1\t2\t3", name=cool_name_with_quote)
            assert hda1["name"] == cool_name_with_quote

            rval = self._run(
                tool_id="model_attributes",
                inputs={"input1": dataset_to_param(hda1)},
                history_id=history_id,
                assert_ok=True,
                wait_for_job=True,
            )
            sanitized_dataset_name = self.dataset_populator.get_history_dataset_content(
                history_id, dataset=rval["outputs"][0]
            )
            assert sanitized_dataset_name.strip() == cool_name_without_quote

            sanitized_email = self.dataset_populator.get_history_dataset_content(history_id, dataset=rval["outputs"][1])
            assert '"' not in sanitized_email

            sanitized_address = self.dataset_populator.get_history_dataset_content(
                history_id, dataset=rval["outputs"][2]
            )
            assert sanitized_address.strip() == cool_name_without_quote

    def test_fetch_workbook_generate(self):
        workbook_path = self.dataset_populator.download_fetch_workbook()
        assert_file_looks_like_xlsx(workbook_path)

    @skip_without_tool("composite_output")
    def test_test_data_filepath_security(self):
        test_data_response = self._get("tools/composite_output/test_data_path?filename=../CONTRIBUTORS.md", admin=True)
        assert test_data_response.status_code == 404, test_data_response.text

    @skip_without_tool("composite_output")
    def test_test_data_admin_security(self):
        test_data_response = self._get("tools/composite_output/test_data_path?filename=../CONTRIBUTORS.md")
        assert test_data_response.status_code == 403, test_data_response.text

    @skip_without_tool("dbkey_filter_multi_input")
    def test_data_table_requirement_annotated(self):
        test_data_response = self._get("tools/dbkey_filter_multi_input/test_data")
        assert test_data_response.status_code == 200
        test_case = test_data_response.json()[0]
        assert test_case["required_data_tables"][0] == "test_fasta_indexes"
        assert len(test_case["required_loc_files"]) == 0

    @skip_without_tool("composite_output")
    def test_test_data_composite_output(self):
        test_data_response = self._get("tools/composite_output/test_data")
        assert test_data_response.status_code == 200
        test_data = test_data_response.json()
        assert len(test_data) == 1
        test_case = test_data[0]
        self._assert_has_keys(test_case, "inputs", "outputs", "output_collections", "required_files")
        assert len(test_case["inputs"]) == 1, test_case
        # input0 = next(iter(test_case["inputs"].values()))

    @skip_without_tool("collection_two_paired")
    def test_test_data_collection_two_paired(self):
        test_data_response = self._get("tools/collection_two_paired/test_data")
        assert test_data_response.status_code == 200
        test_data = test_data_response.json()
        assert len(test_data) == 2
        test_case = test_data[0]
        assert len(test_case["required_data_tables"]) == 0
        assert len(test_case["required_loc_files"]) == 0
        self._assert_has_keys(test_case, "inputs", "outputs", "output_collections", "required_files")
        assert len(test_case["inputs"]) == 3, test_case

    @skip_without_tool("collection_nested_test")
    def test_test_data_collection_nested(self):
        test_data_response = self._get("tools/collection_nested_test/test_data")
        assert test_data_response.status_code == 200
        test_data = test_data_response.json()
        assert len(test_data) == 2
        test_case = test_data[0]
        self._assert_has_keys(test_case, "inputs", "outputs", "output_collections", "required_files")
        assert len(test_case["inputs"]) == 1, test_case

    @skip_without_tool("expression_null_handling_boolean")
    def test_test_data_null_boolean_inputs(self):
        test_data_response = self._get("tools/expression_null_handling_boolean/test_data")
        assert test_data_response.status_code == 200
        test_data = test_data_response.json()
        assert len(test_data) == 3
        test_case = test_data[2]
        self._assert_has_keys(test_case, "inputs", "outputs", "output_collections", "required_files")
        inputs = test_case["inputs"]
        assert len(inputs) == 1, test_case
        assert "bool_input" in inputs, inputs
        assert inputs["bool_input"] is None, inputs

    @skip_without_tool("simple_constructs_y")
    def test_test_data_yaml_tools(self):
        test_data_response = self._get("tools/simple_constructs_y/test_data")
        assert test_data_response.status_code == 200
        test_data = test_data_response.json()
        assert len(test_data) == 3

    @skip_without_tool("cat1")
    def test_test_data_download(self):
        test_data_response = self._get("tools/cat1/test_data_download?filename=1.bed")
        assert test_data_response.status_code == 200, test_data_response.text.startswith("chr")

    @skip_without_tool("composite_output")
    def test_test_data_downloads_security(self):
        test_data_response = self._get("tools/composite_output/test_data_download?filename=../CONTRIBUTORS.md")
        assert test_data_response.status_code == 404, test_data_response.text

    @skip_without_tool("composite_output")
    def test_test_data_download_composite(self):
        test_data_response = self._get("tools/composite_output/test_data_download?filename=velveth_test1")
        assert test_data_response.status_code == 200
        with zipfile.ZipFile(BytesIO(test_data_response.content)) as contents:
            namelist = contents.namelist()
        assert len(namelist) == 6
        expected_names = {
            "velveth_test1/Roadmaps",
            "velveth_test1/output.html",
            "velveth_test1/Sequences",
            "velveth_test1/Log",
            "velveth_test1/output/",
            "velveth_test1/output/1",
        }
        assert set(namelist) == expected_names

    def test_convert_dataset_explicit_history(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            fasta1_contents = open(self.get_filename("1.fasta")).read()
            hda1 = self.dataset_populator.new_dataset(history_id, content=fasta1_contents)

            payload = {
                "src": "hda",
                "id": hda1["id"],
                "source_type": "fasta",
                "target_type": "tabular",
                "history_id": history_id,
            }
            create_response = self._post("tools/CONVERTER_fasta_to_tabular/convert", data=payload)
            self.dataset_populator.wait_for_job(create_response.json()["jobs"][0]["id"], assert_ok=True)
            create_response.raise_for_status()
            assert len(create_response.json()["implicit_collections"]) == 0
            for output in create_response.json()["outputs"]:
                assert output["file_ext"] == "tabular"

    def test_convert_dataset_implicit_history(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            fasta1_contents = open(self.get_filename("1.fasta")).read()
            hda1 = self.dataset_populator.new_dataset(history_id, content=fasta1_contents)

            payload = {"src": "hda", "id": hda1["id"], "source_type": "fasta", "target_type": "tabular"}
            create_response = self._post("tools/CONVERTER_fasta_to_tabular/convert", data=payload)
            self.dataset_populator.wait_for_job(create_response.json()["jobs"][0]["id"], assert_ok=True)
            create_response.raise_for_status()
            assert len(create_response.json()["implicit_collections"]) == 0
            for output in create_response.json()["outputs"]:
                assert output["file_ext"] == "tabular"

    def test_convert_hdca(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            data = [
                {
                    "name": "test0",
                    "elements": [
                        {"src": "pasted", "paste_content": "123\n", "name": "forward", "ext": "fasta"},
                        {"src": "pasted", "paste_content": "456\n", "name": "reverse", "ext": "fasta"},
                    ],
                },
                {
                    "name": "test1",
                    "elements": [
                        {"src": "pasted", "paste_content": "789\n", "name": "forward", "ext": "fasta"},
                        {"src": "pasted", "paste_content": "0ab\n", "name": "reverse", "ext": "fasta"},
                    ],
                },
            ]
            hdca1 = self.dataset_collection_populator.upload_collection(
                history_id, "list:paired", elements=data, wait=True
            )
            self._assert_status_code_is(hdca1, 200)

            payload = {
                "src": "hdca",
                "id": hdca1.json()["outputs"][0]["id"],
                "source_type": "fasta",
                "target_type": "tabular",
                "history_id": history_id,
            }
            create_response = self._post("tools/CONVERTER_fasta_to_tabular/convert", payload)
            create_response.raise_for_status()
            assert create_response.json()["implicit_collections"] != []
            hid = create_response.json()["implicit_collections"][0]["hid"]
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            collection_details = self.dataset_populator.get_history_collection_details(history_id, hid=hid)
            for element in collection_details["elements"][0]["object"]["elements"]:
                assert element["object"]["file_ext"] == "tabular"

    def test_unzip_collection(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            hdca_id = self._build_pair(history_id, ["123", "456"])
            inputs = {
                "input": {"src": "hdca", "id": hdca_id},
            }
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            response = self._run("__UNZIP_COLLECTION__", history_id, inputs, assert_ok=True)
            outputs = response["outputs"]
            assert len(outputs) == 2
            output_forward = outputs[0]
            output_reverse = outputs[1]
            output_forward_content = self.dataset_populator.get_history_dataset_content(
                history_id, dataset=output_forward
            )
            output_reverse_content = self.dataset_populator.get_history_dataset_content(
                history_id, dataset=output_reverse
            )
            assert output_forward_content.strip() == "123"
            assert output_reverse_content.strip() == "456"

            output_forward = self.dataset_populator.get_history_dataset_details(history_id, dataset=output_forward)
            output_reverse = self.dataset_populator.get_history_dataset_details(history_id, dataset=output_reverse)

            assert output_forward["history_id"] == history_id
            assert output_reverse["history_id"] == history_id

    def test_unzip_nested(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            response = self.dataset_collection_populator.upload_collection(
                history_id,
                "list:paired",
                elements=[
                    {
                        "name": "test0",
                        "elements": [
                            {
                                "src": "pasted",
                                "paste_content": "123\n",
                                "name": "forward",
                                "ext": "txt",
                                "tags": ["#foo"],
                            },
                            {
                                "src": "pasted",
                                "paste_content": "456\n",
                                "name": "reverse",
                                "ext": "txt",
                                "tags": ["#bar"],
                            },
                        ],
                    }
                ],
                wait=True,
            )
            self._assert_status_code_is(response, 200)
            hdca_id = response.json()["outputs"][0]["id"]
            inputs = {
                "input": {
                    "batch": True,
                    "values": [{"src": "hdca", "map_over_type": "paired", "id": hdca_id}],
                }
            }
            response = self._run("__UNZIP_COLLECTION__", history_id, inputs, assert_ok=True)
            implicit_collections = response["implicit_collections"]
            assert len(implicit_collections) == 2
            unzipped_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=implicit_collections[0]["hid"]
            )
            assert unzipped_hdca["elements"][0]["element_type"] == "hda", unzipped_hdca

    def test_zip_inputs(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            hda1 = dataset_to_param(self.dataset_populator.new_dataset(history_id, content="1\t2\t3"))
            hda2 = dataset_to_param(self.dataset_populator.new_dataset(history_id, content="4\t5\t6"))
            inputs = {
                "input_forward": hda1,
                "input_reverse": hda2,
            }
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            response = self._run("__ZIP_COLLECTION__", history_id, inputs, assert_ok=True)
            output_collections = response["output_collections"]
            assert len(output_collections) == 1
            self.dataset_populator.wait_for_job(response["jobs"][0]["id"], assert_ok=True)
            zipped_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=output_collections[0]["hid"]
            )
            assert zipped_hdca["collection_type"] == "paired"

    @skip_without_tool("__ZIP_COLLECTION__")
    def test_collection_operation_dataset_input_permissions(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            hda1 = dataset_to_param(self.dataset_populator.new_dataset(history_id, content="1\t2\t3"))
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            self.dataset_populator.make_private(history_id, hda1["id"])
            inputs = {
                "input_forward": hda1,
                "input_reverse": hda1,
            }
            with self._different_user_and_history() as other_history_id:
                response = self._run("__ZIP_COLLECTION__", other_history_id, inputs, assert_ok=False)
                self._assert_dataset_permission_denied_response(response)

    @skip_without_tool("__UNZIP_COLLECTION__")
    def test_collection_operation_collection_input_permissions(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            create_response = self.dataset_collection_populator.create_pair_in_history(
                history_id, direct_upload=True, wait=True
            )
            self._assert_status_code_is(create_response, 200)
            collection = create_response.json()["outputs"][0]
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            collection = self.dataset_populator.get_history_collection_details(history_id, hid=collection["hid"])
            element_id = collection["elements"][0]["object"]["id"]
            self.dataset_populator.make_private(history_id, element_id)
            inputs = {
                "input": {"src": "hdca", "id": collection["id"]},
            }
            with self._different_user_and_history() as other_history_id:
                response = self._run("__UNZIP_COLLECTION__", other_history_id, inputs, assert_ok=False)
                self._assert_dataset_permission_denied_response(response)

    def test_zip_list_inputs(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            hdca1_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["a\nb\nc\nd", "e\nf\ng\nh"], wait=True
            ).json()["outputs"][0]["id"]

            hdca2_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["1\n2\n3\n4", "5\n6\n7\n8"], wait=True
            ).json()["outputs"][0]["id"]
            inputs = {
                "input_forward": {"batch": True, "values": [{"src": "hdca", "id": hdca1_id}]},
                "input_reverse": {"batch": True, "values": [{"src": "hdca", "id": hdca2_id}]},
            }
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            response = self._run("__ZIP_COLLECTION__", history_id, inputs, assert_ok=True)
            implicit_collections = response["implicit_collections"]
            assert len(implicit_collections) == 1
            self.dataset_populator.wait_for_job(response["jobs"][0]["id"], assert_ok=True)
            zipped_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=implicit_collections[0]["hid"]
            )
            assert zipped_hdca["collection_type"] == "list:paired"

    @skip_without_tool("__EXTRACT_DATASET__")
    @skip_without_tool("cat_data_and_sleep")
    def test_database_operation_tool_with_pending_inputs(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            hdca1_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["a\nb\nc\nd", "e\nf\ng\nh"], wait=True
            ).json()["outputs"][0]["id"]
            run_response = self.dataset_populator.run_tool(
                tool_id="cat_data_and_sleep",
                inputs={
                    "sleep_time": 15,
                    "input1": {"batch": True, "values": [{"src": "hdca", "id": hdca1_id}]},
                },
                history_id=history_id,
            )
            output_hdca_id = run_response["implicit_collections"][0]["id"]
            run_response = self.dataset_populator.run_tool(
                tool_id="__EXTRACT_DATASET__",
                inputs={
                    "data_collection": {"src": "hdca", "id": output_hdca_id},
                },
                history_id=history_id,
            )
            assert run_response["outputs"][0]["state"] != "ok"

    @skip_without_tool("__EXTRACT_DATASET__")
    def test_extract_dataset_invalid_element_identifier(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            hdca1_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["a\nb\nc\nd", "e\nf\ng\nh"], wait=True
            ).json()["outputs"][0]["id"]
            run_response = self.dataset_populator.run_tool_raw(
                tool_id="__EXTRACT_DATASET__",
                inputs={
                    "data_collection": {"src": "hdca", "id": hdca1_id},
                    "which": {"which_dataset": "by_index", "index": 100},
                },
                history_id=history_id,
                input_format="21.01",
            )
            assert run_response.status_code == 400
            assert run_response.json()["err_msg"] == "Dataset collection has no element_index with key 100."

    @skip_without_tool("__CONVERT_SAMPLE_SHEET__")
    def test_convert_sample_sheet_to_list(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            # Create sample_sheet collection with column_definitions and rows
            create_response = self.dataset_collection_populator.create_sample_sheet(
                history_id,
                contents=[("sample1", "content1"), ("sample2", "content2")],
                column_definitions=[
                    {"type": "int", "name": "replicate", "optional": False},
                    {"type": "string", "name": "treatment", "optional": False},
                ],
                rows={"sample1": [1, "control"], "sample2": [2, "treatment"]},
            )
            self._assert_status_code_is(create_response, 200)
            sample_sheet_hdca = create_response.json()
            assert sample_sheet_hdca["collection_type"] == "sample_sheet"
            assert sample_sheet_hdca["column_definitions"] is not None

            # Run convert sample sheet tool
            inputs = {"input": {"src": "hdca", "id": sample_sheet_hdca["id"]}}
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            response = self._run("__CONVERT_SAMPLE_SHEET__", history_id, inputs, assert_ok=True)

            # Verify output is a list collection without sample sheet metadata
            output_collections = response["output_collections"]
            assert len(output_collections) == 1
            self.dataset_populator.wait_for_job(response["jobs"][0]["id"], assert_ok=True)
            converted_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=output_collections[0]["hid"]
            )
            assert converted_hdca["collection_type"] == "list"
            assert converted_hdca.get("column_definitions") is None
            assert len(converted_hdca["elements"]) == 2
            element_identifiers = [e["element_identifier"] for e in converted_hdca["elements"]]
            assert "sample1" in element_identifiers
            assert "sample2" in element_identifiers

    @skip_without_tool("__CONVERT_SAMPLE_SHEET__")
    def test_convert_sample_sheet_paired_to_list_paired(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            # Create sample_sheet:paired collection
            pair_identifiers = self.dataset_collection_populator.pair_identifiers(history_id, ["forward", "reverse"])
            element_identifiers = [
                {
                    "name": "sample1",
                    "collection_type": "paired",
                    "src": "new_collection",
                    "element_identifiers": pair_identifiers,
                }
            ]
            create_response = self.dataset_collection_populator.create_sample_sheet(
                history_id,
                contents=element_identifiers,
                column_definitions=[{"type": "int", "name": "replicate", "default_value": 0, "optional": False}],
                rows={"sample1": [42]},
                collection_type="sample_sheet:paired",
            )
            self._assert_status_code_is(create_response, 200)
            sample_sheet_hdca = create_response.json()
            assert sample_sheet_hdca["collection_type"] == "sample_sheet:paired"
            assert sample_sheet_hdca["column_definitions"] is not None

            # Run convert sample sheet tool
            inputs = {"input": {"src": "hdca", "id": sample_sheet_hdca["id"]}}
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            response = self._run("__CONVERT_SAMPLE_SHEET__", history_id, inputs, assert_ok=True)

            # Verify output is a list:paired collection without sample sheet metadata
            output_collections = response["output_collections"]
            assert len(output_collections) == 1
            self.dataset_populator.wait_for_job(response["jobs"][0]["id"], assert_ok=True)
            converted_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=output_collections[0]["hid"]
            )
            assert converted_hdca["collection_type"] == "list:paired"
            assert converted_hdca.get("column_definitions") is None
            assert len(converted_hdca["elements"]) == 1
            # Verify nested paired structure is preserved
            element = converted_hdca["elements"][0]
            assert element["element_type"] == "dataset_collection"
            assert element["object"]["collection_type"] == "paired"
            assert len(element["object"]["elements"]) == 2

    @skip_without_tool("__FILTER_FAILED_DATASETS__")
    def test_filter_failed_list(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            ok_hdca_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["0", "1", "0", "1"], wait=True
            ).json()["outputs"][0]["id"]
            response = self.dataset_populator.run_exit_code_from_file(history_id, ok_hdca_id)

            mixed_implicit_collections = response["implicit_collections"]
            assert len(mixed_implicit_collections) == 1
            mixed_hdca_hid = mixed_implicit_collections[0]["hid"]
            mixed_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=mixed_hdca_hid, wait=False
            )

            def get_state(dce):
                return dce["object"]["state"]

            mixed_states = [get_state(_) for _ in mixed_hdca["elements"]]
            assert mixed_states == ["ok", "error", "ok", "error"], mixed_states

            filtered_hdca = self._run_filter(history_id=history_id, failed_hdca_id=mixed_hdca["id"])
            filtered_states = [get_state(_) for _ in filtered_hdca["elements"]]
            assert filtered_states == ["ok", "ok"], filtered_states

    @skip_without_tool("__FILTER_FAILED_DATASETS__")
    def test_filter_failed_list_paired(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            pair1 = self.dataset_collection_populator.create_pair_in_history(
                history_id, contents=["0", "0"], wait=True
            ).json()["outputs"][0]["id"]
            pair2 = self.dataset_collection_populator.create_pair_in_history(
                history_id, contents=["0", "1"], wait=True
            ).json()["outputs"][0]["id"]
            ok_hdca_id = self.dataset_collection_populator.create_list_from_pairs(history_id, [pair1, pair2]).json()[
                "id"
            ]
            response = self.dataset_populator.run_exit_code_from_file(history_id, ok_hdca_id)

            mixed_implicit_collections = response["implicit_collections"]
            assert len(mixed_implicit_collections) == 1
            mixed_hdca_hid = mixed_implicit_collections[0]["hid"]
            mixed_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=mixed_hdca_hid, wait=False
            )

            def get_state(dce):
                return dce["object"]["state"]

            mixed_states = [get_state(element) for _ in mixed_hdca["elements"] for element in _["object"]["elements"]]
            assert mixed_states == ["ok", "ok", "ok", "error"], mixed_states

            filtered_hdca = self._run_filter(history_id=history_id, failed_hdca_id=mixed_hdca["id"])
            filtered_states = [
                get_state(element) for _ in filtered_hdca["elements"] for element in _["object"]["elements"]
            ]
            assert filtered_states == ["ok", "ok"], filtered_states

            # Also try list:list:paired
            llp = self.dataset_collection_populator.create_nested_collection(
                history_id=history_id, collection_type="list:list:paired", collection=[mixed_hdca["id"]]
            ).json()

            filtered_nested_hdca = self._run_filter(history_id=history_id, failed_hdca_id=llp["id"], batch=True)
            filtered_states = [
                get_state(element)
                for _ in filtered_nested_hdca["elements"]
                for parent_element in _["object"]["elements"]
                for element in parent_element["object"]["elements"]
            ]
            assert filtered_states == ["ok", "ok"], filtered_states

    def _run_filter(self, history_id, failed_hdca_id, batch=False):
        if batch:
            inputs = {
                "input": {
                    "batch": batch,
                    "values": [{"map_over_type": "list:paired", "src": "hdca", "id": failed_hdca_id}],
                },
            }
        else:
            inputs = {
                "input": {"batch": batch, "src": "hdca", "id": failed_hdca_id},
            }
        response = self._run("__FILTER_FAILED_DATASETS__", history_id, inputs, assert_ok=False).json()
        self.dataset_populator.wait_for_history(history_id, assert_ok=False)
        filter_output_collections = response["output_collections"]
        if batch:
            return response["implicit_collections"][0]
        assert len(filter_output_collections) == 1
        filtered_hid = filter_output_collections[0]["hid"]
        filtered_hdca = self.dataset_populator.get_history_collection_details(history_id, hid=filtered_hid, wait=False)
        return filtered_hdca

    def _apply_rules_and_check(self, example: dict[str, Any]) -> None:
        with self.dataset_populator.test_history(require_new=False) as history_id:
            inputs = stage_rules_example(self.galaxy_interactor, history_id, example)
            hdca = inputs["input"]
            inputs = {"input": {"src": "hdca", "id": hdca["id"]}, "rules": example["rules"]}
            self.dataset_populator.wait_for_history(history_id)
            response = self._run("__APPLY_RULES__", history_id, inputs, assert_ok=True)
            output_collections = response["output_collections"]
            assert len(output_collections) == 1
            output_hid = output_collections[0]["hid"]
            output_hdca = self.dataset_populator.get_history_collection_details(history_id, hid=output_hid, wait=False)
            example["check"](output_hdca, self.dataset_populator)

    def test_apply_rules_with_error_in_mapping(self):
        # this would produce a list:paired but the child identifiers are incorrectly spelled
        example_with_mapping_error = {
            "rules": {
                "rules": [
                    {
                        "type": "add_column_metadata",
                        "value": "identifier0",
                    },
                    {
                        "type": "add_column_metadata",
                        "value": "identifier1",
                    },
                ],
                "mapping": [
                    {
                        "type": "list_identifiers",
                        "columns": [0],
                    },
                    {
                        "type": "paired_identifier",
                        "columns": [1],
                    },
                ],
            },
            "test_data": {
                "type": "list:list",
                "elements": [
                    {
                        "identifier": "sample1",
                        "elements": [
                            {"identifier": "floorward", "class": "File", "contents": "TestData123forward"},
                            {"identifier": "reverb", "class": "File", "contents": "TestData123reverse"},
                        ],
                    },
                ],
            },
        }
        with self.dataset_populator.test_history(require_new=False) as history_id:
            inputs = stage_rules_example(self.galaxy_interactor, history_id, example_with_mapping_error)
            hdca = inputs["input"]
            inputs = {"input": {"src": "hdca", "id": hdca["id"]}, "rules": example_with_mapping_error["rules"]}

            self.dataset_populator.wait_for_history(history_id)
            response = self._run("__APPLY_RULES__", history_id, inputs, assert_ok=False)
            assert_status_code_is(response, 400)
            assert_error_code_is(response, 400008)
            assert "Unknown indicator of paired status encountered (floorward)" in response.json()["err_msg"]

    def test_apply_rules_flatten_paired_unpaired(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_FLATTEN_PAIRED_OR_UNPAIRED)

    def test_apply_rules_1(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_1)

    def test_apply_rules_2(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_2)

    def test_apply_rules_3(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_3)

    def test_apply_rules_4(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_4)

    def test_apply_rules_5(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_5)

    def test_apply_rules_6(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_6)

    def test_apply_rules_create_paired_or_unpaired_list(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_CREATE_PAIRED_OR_UNPAIRED_COLLECTION)

    def test_apply_rules_flatten_with_indices(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_FLATTEN_USING_INDICES)

    def test_apply_rules_nested_list_from_sample_sheet(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_SAMPLE_SHEET_SIMPLE_TO_NESTED_LIST)

    def test_apply_rules_nested_list_of_pairs_from_sample_sheet(self):
        self._apply_rules_and_check(rules_test_data.EXAMPLE_SAMPLE_SHEET_SIMPLE_TO_NESTED_LIST_OF_PAIRS)

    @skip_without_tool("galaxy_json_sleep")
    def test_dataset_hidden_after_job_finish(self):
        with self.dataset_populator.test_history() as history_id:
            inputs = {
                "sleep_time": 5,
            }
            response = self._run("galaxy_json_sleep", history_id, inputs, assert_ok=True)
            output = response["outputs"][0]
            response = self._put(
                f"histories/{history_id}/contents/datasets/{output['id']}", data={"visible": False}, json=True
            )
            response.raise_for_status()
            output_details = self.dataset_populator.get_history_dataset_details(history_id, dataset=output, wait=False)
            assert not output_details["visible"]
            output_details = self.dataset_populator.get_history_dataset_details(history_id, dataset=output, wait=True)
            assert not output_details["visible"]

    @skip_without_tool("gx_drill_down_exact")
    @skip_without_tool("gx_drill_down_exact_multiple")
    @skip_without_tool("gx_drill_down_recurse")
    @skip_without_tool("gx_drill_down_recurse_multiple")
    def test_drill_down_first_by_default(self):
        # we have a tool test for this but I wanted to verify it wasn't just the
        # tool test framework filling in a default. Creating a raw request here
        # verifies that currently select parameters don't require a selection.
        with self.dataset_populator.test_history(require_new=False) as history_id:
            inputs: dict[str, Any] = {}
            response = self._run("gx_drill_down_exact", history_id, inputs, assert_ok=False)
            self._assert_status_code_is(response, 400)
            assert "an invalid option" in response.text

            response = self._run("gx_drill_down_exact_multiple", history_id, inputs, assert_ok=False)
            self._assert_status_code_is(response, 400)
            assert "an invalid option" in response.text

            response = self._run("gx_drill_down_recurse", history_id, inputs, assert_ok=False)
            self._assert_status_code_is(response, 400)
            assert "an invalid option" in response.text

            response = self._run("gx_drill_down_recurse_multiple", history_id, inputs, assert_ok=False)
            self._assert_status_code_is(response, 400)
            assert "an invalid option" in response.text

            # having an initially selected value - is useful for the UI but doesn't serve
            # as a default and doesn't make the drill down optional in a someway.
            response = self._run("gx_drill_down_exact_with_selection", history_id, inputs, assert_ok=True)
            output = response["outputs"][0]
            output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output)
            assert output1_content.strip() == "parameter: aba"

    def test_data_column_defaults(self):
        for input_format in ["legacy", "21.01"]:
            tabular_contents = "1\t2\t3\t\n4\t5\t6\n"
            with self.dataset_populator.test_history(require_new=True) as history_id:
                hda = dataset_to_param(
                    self.dataset_populator.new_dataset(history_id, content=tabular_contents, file_type="tabular")
                )
                details = self.dataset_populator.get_history_dataset_details(history_id, dataset=hda, assert_ok=True)
                inputs = {"ref_parameter": hda}
                response = self._run(
                    "gx_data_column", history_id, inputs, assert_ok=False, input_format=input_format
                ).json()
                output = response["outputs"]
                details = self.dataset_populator.get_history_dataset_details(
                    history_id, dataset=output[0], assert_ok=False
                )
                assert details["state"] == "ok"

                bed1_contents = open(self.get_filename("1.bed")).read()
                hda = dataset_to_param(
                    self.dataset_populator.new_dataset(history_id, content=bed1_contents, file_type="bed")
                )
                details = self.dataset_populator.get_history_dataset_details(history_id, dataset=hda, assert_ok=True)
                inputs = {"ref_parameter": hda}
                response = self._run(
                    "gx_data_column", history_id, inputs, assert_ok=False, input_format=input_format
                ).json()
                output = response["outputs"]
                details = self.dataset_populator.get_history_dataset_details(
                    history_id, dataset=output[0], assert_ok=False
                )
                assert details["state"] == "ok"

            with self.dataset_populator.test_history(require_new=False) as history_id:
                response = self._run("gx_data_column_multiple", history_id, inputs, assert_ok=False).json()
                assert "err_msg" in response, str(response)
                assert "Parameter 'parameter': an invalid option" in response["err_msg"]

            with self.dataset_populator.test_history(require_new=True) as history_id:
                response = self._run("gx_data_column_optional", history_id, inputs, assert_ok=True)
                output = response["outputs"]
                content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output[0])
                assert "parameter: None" in content

                response = self._run("gx_data_column_with_default", history_id, inputs, assert_ok=True)
                output = response["outputs"]
                content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output[0])
                assert "parameter: 2" in content

                response = self._run("gx_data_column_with_default_legacy", history_id, inputs, assert_ok=True)
                output = response["outputs"]
                content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output[0])
                assert "parameter: 3" in content

                response = self._run("gx_data_column_accept_default", history_id, inputs, assert_ok=True)
                output = response["outputs"]
                content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output[0])
                assert "parameter: 1" in content

                response = self._run("gx_data_column_multiple_with_default", history_id, inputs, assert_ok=True)
                output = response["outputs"]
                content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output[0])
                assert "parameter: 1,2" in content

    @skip_without_tool("cat1")
    def test_run_cat1(self):
        with self.dataset_populator.test_history(require_new=False) as history_id:
            # Run simple non-upload tool with an input data parameter.
            new_dataset = self.dataset_populator.new_dataset(history_id, content="Cat1Test")
            inputs = dict(
                input1=dataset_to_param(new_dataset),
            )
            outputs = self._cat1_outputs(history_id, inputs=inputs)
            assert len(outputs) == 1
            output1 = outputs[0]
            output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
            assert output1_content.strip() == "Cat1Test"

    def _get_cat1_inputs(self, history_id):
        new_dataset = self.dataset_populator.new_dataset(history_id, content="Cat1Test")
        inputs = dict(
            input1=dataset_to_param(new_dataset),
        )
        return inputs

    @skip_without_tool("cat1")
    @requires_new_history
    def test_run_cat1_use_cached_job(self):
        with self.dataset_populator.test_history_for(self.test_run_cat1_use_cached_job) as history_id:
            # Run simple non-upload tool with an input data parameter.
            inputs = self._get_cat1_inputs(history_id)
            outputs_one = self._run_cat1(history_id, inputs=inputs, assert_ok=True, wait_for_job=True)
            outputs_two = self._run_cat1(
                history_id, inputs=inputs, use_cached_job=False, assert_ok=True, wait_for_job=True
            )
            outputs_three = self._run_cat1(
                history_id, inputs=inputs, use_cached_job=True, assert_ok=True, wait_for_job=True
            )
            dataset_details = []
            for output in [outputs_one, outputs_two, outputs_three]:
                output_id = output["outputs"][0]["id"]
                dataset_details.append(self._get(f"datasets/{output_id}").json())
                assert self._get(f"jobs/{output['jobs'][0]['id']}/metrics").json()
            filenames = [dd["file_name"] for dd in dataset_details]
            assert len(filenames) == 3, filenames
            assert len(set(filenames)) <= 2, filenames

    @skip_without_tool("cat1")
    @requires_new_history
    def test_run_cat1_use_cached_job_build_list(self):
        with self.dataset_populator.test_history_for(self.test_run_cat1_use_cached_job) as history_id:
            # Run simple non-upload tool with an input data parameter.
            inputs = self._get_cat1_inputs(history_id)
            outputs_one = self._run_cat1(history_id, inputs=inputs, assert_ok=True, wait_for_job=True)
            outputs_two = self._run_cat1(
                history_id, inputs=inputs, use_cached_job=False, assert_ok=True, wait_for_job=True
            )
            # Rename inputs. Job should still be cached since cat1 doesn't look at name attribute
            self.dataset_populator.rename_dataset(inputs["input1"]["id"])
            outputs_three = self._run_cat1(
                history_id, inputs=inputs, use_cached_job=True, assert_ok=False, wait_for_job=False
            ).json()
            outputs_four = self._run(
                "__BUILD_LIST__",
                history_id=history_id,
                inputs={"datasets_0|input": {"src": "hda", "id": outputs_three["outputs"][0]["id"]}},
            ).json()
            self.dataset_populator.wait_for_job(outputs_three["jobs"][0]["id"])
            dataset_details = []
            for output in [outputs_one, outputs_two, outputs_three]:
                output_id = output["outputs"][0]["id"]
                dataset_details.append(self._get(f"datasets/{output_id}").json())
                assert self._get(f"jobs/{output['jobs'][0]['id']}/metrics").json()
            filenames = [dd["file_name"] for dd in dataset_details]
            assert len(filenames) == 3, filenames
            assert len(set(filenames)) <= 2, filenames
            hdca = self.dataset_populator.get_history_collection_details(
                history_id, content_id=outputs_four["output_collections"][0]["id"]
            )
            assert self.dataset_populator.get_history_dataset_content(
                history_id, content_id=hdca["elements"][0]["object"]["id"]
            )

    @skip_without_tool("cat_list")
    @skip_without_tool("__SORTLIST__")
    def test_run_cat_list_hdca_sort_order_respecrted_use_cached_job(self):
        with self.dataset_populator.test_history_for(
            self.test_run_cat_list_hdca_sort_order_respecrted_use_cached_job
        ) as history_id:
            fetch_response = self.dataset_collection_populator.create_list_in_history(
                history_id, wait=True, contents=[("C", "3"), ("B", "2"), ("A", "1")]
            ).json()
            hdca_not_sorted_id = fetch_response["output_collections"][0]["id"]
            result = self._run(
                tool_id="__SORTLIST__",
                history_id=history_id,
                inputs={"input": {"src": "hdca", "id": hdca_not_sorted_id}},
                assert_ok=True,
            )
            hdca_sorted_id = result["output_collections"][0]["id"]
            self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_sorted_id)
            hdca_sorted = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_sorted_id)
            hdca_not_sorted = self.dataset_populator.get_history_collection_details(
                history_id, content_id=hdca_not_sorted_id
            )
            assert hdca_sorted["elements"][0]["object"]["name"] == "A"
            assert hdca_not_sorted["elements"][0]["object"]["name"] == "C"
            self._run("cat_list", history_id, inputs={"input1": {"src": "hdca", "id": hdca_sorted_id}}, assert_ok=True)
            job = self._run(
                "cat_list",
                history_id,
                inputs={"input1": {"src": "hdca", "id": hdca_not_sorted_id}},
                assert_ok=True,
                use_cached_job=True,
            )
            job_details = self.dataset_populator.get_job_details(job["jobs"][0]["id"], full=True).json()
            assert not job_details["copied_from_job_id"]

    @skip_without_tool("cat1")
    @requires_new_history
    def test_run_cat1_use_cached_job_from_public_history(self):
        with self.dataset_populator.test_history_for(self.test_run_cat1_use_cached_job) as history_id:
            # Run simple non-upload tool with an input data parameter.
            inputs = self._get_cat1_inputs(history_id)
            original_output = self._run_cat1(history_id, inputs=inputs, assert_ok=True, wait_for_job=True)
            original_job = self.dataset_populator.get_job_details(original_output["jobs"][0]["id"], full=True).json()

            def run_again(user_email):
                with self._different_user_and_history(user_email=user_email) as different_history_id:
                    cached_output = self._run_cat1(
                        different_history_id, inputs=inputs, use_cached_job=True, assert_ok=True, wait_for_job=True
                    )
                    return self.dataset_populator.get_job_details(cached_output["jobs"][0]["id"], full=True).json()

            job = run_again(f"{uuid4()}@test.com")
            assert job["user_id"] != original_job["user_id"]
            assert not job["copied_from_job_id"]
            # publish history, now we can use cached job
            self.dataset_populator.make_public(history_id=history_id)
            cached_job = run_again(f"{uuid4()}@test.com")
            assert cached_job["user_id"] != original_job["user_id"]
            assert cached_job["copied_from_job_id"] == original_output["jobs"][0]["id"]

    @skip_without_tool("cat1")
    @requires_new_history
    def test_run_cat1_use_cached_job_renamed_input(self):
        with self.dataset_populator.test_history_for(self.test_run_cat1_use_cached_job_renamed_input) as history_id:
            # Run simple non-upload tool with an input data parameter.
            inputs = self._get_cat1_inputs(history_id)
            outputs_one = self._run_cat1(history_id, inputs=inputs, assert_ok=True, wait_for_job=True)
            # Rename inputs. Job should still be cached since cat1 doesn't look at name attribute
            self.dataset_populator.rename_dataset(inputs["input1"]["id"])
            outputs_two = self._run_cat1(
                history_id, inputs=inputs, use_cached_job=True, assert_ok=True, wait_for_job=True
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] == outputs_one["jobs"][0]["id"]

    @skip_without_tool("cat_list")
    @requires_new_history
    def test_run_cat_list_use_cached_job_repeated_input(self):
        with self.dataset_populator.test_history_for(
            self.test_run_cat_list_use_cached_job_repeated_input
        ) as history_id:
            # Run simple non-upload tool with an input data parameter.
            input_value = dataset_to_param(self.dataset_populator.new_dataset(history_id=history_id))
            inputs = {"input1": {"batch": False, "values": [input_value, input_value]}}
            outputs_one = self._run("cat_list", history_id, inputs, assert_ok=True, wait_for_job=True)
            outputs_two = self._run(
                "cat_list", history_id, inputs, assert_ok=True, wait_for_job=True, use_cached_job=True
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] == outputs_one["jobs"][0]["id"]

    @skip_without_tool("collection_creates_list")
    @requires_new_history
    def test_run_collection_creates_list_use_cached_job(self):
        with self.dataset_populator.test_history_for(
            self.test_run_collection_creates_list_use_cached_job
        ) as history_id:
            # Run tool that consumes and produces hdca
            create_response = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["123", "456"], wait=True
            ).json()
            hdca = create_response["output_collections"][0]
            outputs_one = self._run(
                "collection_creates_list",
                history_id,
                inputs={"input1": {"src": "hdca", "id": hdca["id"]}},
                assert_ok=True,
                wait_for_job=True,
            )
            outputs_two = self._run(
                "collection_creates_list",
                history_id,
                inputs={"input1": {"src": "hdca", "id": hdca["id"]}},
                assert_ok=True,
                wait_for_job=True,
                use_cached_job=True,
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] == outputs_one["jobs"][0]["id"]

    @skip_without_tool("collection_creates_list")
    @requires_new_history
    def test_run_collection_creates_list_use_cached_job_renamed_input(self):
        with self.dataset_populator.test_history_for(
            self.test_run_collection_creates_list_use_cached_job
        ) as history_id:
            # Run tool that consumes and produces hdca
            create_response = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["123", "456"], wait=True
            ).json()
            hdca = create_response["output_collections"][0]
            outputs_one = self._run(
                "collection_creates_list",
                history_id,
                inputs={"input1": {"src": "hdca", "id": hdca["id"]}},
                assert_ok=True,
                wait_for_job=True,
            )
            self.dataset_populator.rename_collection(hdca["id"])
            outputs_two = self._run(
                "collection_creates_list",
                history_id,
                inputs={"input1": {"src": "hdca", "id": hdca["id"]}},
                assert_ok=True,
                wait_for_job=True,
                use_cached_job=True,
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] == outputs_one["jobs"][0]["id"]

    @skip_without_tool("collection_creates_list_2")
    @requires_new_history
    def test_job_cache_copy_collection(self):
        """Test job caching when collection_creates_list_2 is run with renamed dataset input.

        This tests the scenario where:
        - A tool has both a dataset input and a collection input
        - The tool outputs a flat list (HDA elements) with structured_like
        - The dataset input is renamed between runs
        - Early caching misses due to name mismatch
        - Late caching (require_name_match=False) finds a match
        - copy_from_job is called during job preparation

        This triggers https://github.com/galaxyproject/galaxy/issues/21556
        """
        with self.dataset_populator.test_history_for(self.test_job_cache_copy_collection) as history_id:
            # Create dataset input (header)
            header_dataset = self.dataset_populator.new_dataset(history_id, content="HEADER LINE\n")

            # Create list collection input
            create_response = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["content_1", "content_2"], wait=True
            ).json()
            hdca = create_response["output_collections"][0]

            # First run - creates the job and output collection
            outputs_one = self._run(
                "collection_creates_list_2",
                history_id,
                inputs={
                    "header": {"src": "hda", "id": header_dataset["id"]},
                    "input_collect": {"src": "hdca", "id": hdca["id"]},
                },
                assert_ok=True,
                wait_for_job=True,
            )
            first_job_id = outputs_one["jobs"][0]["id"]

            # Rename the header dataset - this should cause early caching to miss
            # but late caching (with require_name_match=False) should find a match
            self.dataset_populator.rename_dataset(header_dataset["id"], "renamed_header")

            # Second run with use_cached_job=True
            # Early caching: misses due to name mismatch
            # Late caching: finds match via require_name_match=False
            # copy_from_job is called, triggering the bug
            outputs_two = self._run(
                "collection_creates_list_2",
                history_id,
                inputs={
                    "header": {"src": "hda", "id": header_dataset["id"]},
                    "input_collect": {"src": "hdca", "id": hdca["id"]},
                },
                use_cached_job=True,
                assert_ok=True,
                wait_for_job=True,
            )

            # Verify second job completed successfully
            second_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(second_job_id, full=True).json()
            # If the bug exists, the job would fail with ValueError during preparation
            assert job_details["state"] == "ok", f"Job failed with state {job_details['state']}"
            # Verify that late caching was used (job was copied from first job)
            assert (
                job_details["copied_from_job_id"] == first_job_id
            ), "Expected job to be copied from cached job via late caching"

    @skip_without_tool("identifier_single")
    @requires_new_history
    def test_run_identifier_single_map_over_nested_collection_use_cached_job(self):
        with self.dataset_populator.test_history_for(
            self.test_run_identifier_single_use_cached_job_renamed_input
        ) as history_id:
            # Run tool that acccesses input.name (via input.element_identifier).
            hdca = self.dataset_collection_populator.create_list_of_list_in_history(history_id, wait=True).json()
            inputs = {"input1": {"batch": True, "values": [{"src": "hdca", "id": hdca["id"]}]}}
            outputs_one = self._run("identifier_single", history_id, inputs=inputs, assert_ok=True, wait_for_job=True)
            outputs_two = self._run(
                "identifier_single", history_id, inputs=inputs, use_cached_job=True, assert_ok=True, wait_for_job=True
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] == outputs_one["jobs"][0]["id"]

    @skip_without_tool("identifier_single")
    @requires_new_history
    def test_run_identifier_single_use_cached_job_renamed_input(self):
        with self.dataset_populator.test_history_for(
            self.test_run_identifier_single_use_cached_job_renamed_input
        ) as history_id:
            # Run tool that acccesses input.name (via input.element_identifier).
            inputs = self._get_cat1_inputs(history_id)
            self._run("identifier_single", history_id, inputs=inputs, assert_ok=True, wait_for_job=True)
            # Rename inputs. Job should not be cached since tool looks at name attribute
            self.dataset_populator.rename_dataset(inputs["input1"]["id"])
            outputs_two = self._run(
                "identifier_single", history_id, inputs=inputs, use_cached_job=True, assert_ok=True, wait_for_job=True
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] is None

    @skip_without_tool("collection_creates_dynamic_list_of_pairs")
    @requires_new_history
    def test_run_collection_creates_dynamic_list_of_pairs_use_cached_job(self):
        with self.dataset_populator.test_history_for(
            self.test_run_collection_creates_dynamic_list_of_pairs_use_cached_job
        ) as history_id:
            dataset = self.dataset_populator.new_dataset(history_id, content="123")
            outputs_one = self._run(
                "collection_creates_dynamic_list_of_pairs",
                history_id,
                inputs={"file": {"src": "hda", "id": dataset["id"]}, "foo": "abc"},
                assert_ok=True,
                wait_for_job=True,
                use_cached_job=False,
            )
            self.dataset_populator.rename_dataset(dataset["id"])
            outputs_two = self._run(
                "collection_creates_dynamic_list_of_pairs",
                history_id,
                inputs={"file": {"src": "hda", "id": dataset["id"]}, "foo": "abc"},
                assert_ok=True,
                wait_for_job=True,
                use_cached_job=True,
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] == outputs_one["jobs"][0]["id"]
            contents = self.dataset_populator.get_history_contents(history_id)
            # Make sure we add the correct number of output to the history
            # 1 input dataset
            # 2 output collections
            # with 6 HDAs each
            assert len(contents) == 15
            hdca_details = self.dataset_populator.get_history_collection_details(
                history_id=history_id, content_id=outputs_two["output_collections"][0]["id"]
            )
            assert hdca_details["collection_type"] == "list:paired"
            assert hdca_details["element_count"] == 3
            assert hdca_details["populated"]
            assert hdca_details["populated_state"] == "ok"
            assert hdca_details["elements_datatypes"] == ["fastqsanger"]

    @skip_without_tool("multi_output_assign_primary_ext_dbkey")
    @requires_new_history
    def test_run_multi_output_assign_primary_ext_dbkey_use_cached_job(self):
        with self.dataset_populator.test_history_for(
            self.test_run_multi_output_assign_primary_ext_dbkey_use_cached_job
        ) as history_id:
            dataset = self.dataset_populator.new_dataset(history_id, content="123")
            outputs_one = self._run(
                "multi_output_assign_primary_ext_dbkey",
                history_id,
                inputs={"input": {"src": "hda", "id": dataset["id"]}, "num_param": 1},
                assert_ok=True,
                wait_for_job=True,
                use_cached_job=False,
            )
            self.dataset_populator.rename_dataset(dataset["id"])
            outputs_two = self._run(
                "multi_output_assign_primary_ext_dbkey",
                history_id,
                inputs={"input": {"src": "hda", "id": dataset["id"]}, "num_param": 1},
                assert_ok=True,
                wait_for_job=True,
                use_cached_job=True,
            )
            copied_job_id = outputs_two["jobs"][0]["id"]
            job_details = self.dataset_populator.get_job_details(copied_job_id, full=True).json()
            assert job_details["copied_from_job_id"] == outputs_one["jobs"][0]["id"]
            contents = self.dataset_populator.get_history_contents(history_id)
            # Make sure we add the correct number of output to the history
            # 1 input dataset
            # 2 output datasets each
            assert len(contents) == 5

    @skip_without_tool("cat1")
    def test_run_cat1_listified_param(self):
        with self.dataset_populator.test_history_for(self.test_run_cat1_listified_param) as history_id:
            # Run simple non-upload tool with an input data parameter.
            new_dataset = self.dataset_populator.new_dataset(history_id, content="Cat1Testlistified")
            inputs = dict(
                input1=[dataset_to_param(new_dataset)],
            )
            outputs = self._cat1_outputs(history_id, inputs=inputs)
            assert len(outputs) == 1
            output1 = outputs[0]
            output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
            assert output1_content.strip() == "Cat1Testlistified"

    @skip_without_tool("multiple_versions")
    def test_run_by_versions(self):
        with self.dataset_populator.test_history_for(self.test_run_by_versions) as history_id:
            for version in ["0.1", "0.2"]:
                # Run simple non-upload tool with an input data parameter.
                outputs = self._run_and_get_outputs(
                    tool_id="multiple_versions", history_id=history_id, tool_version=version
                )
                assert len(outputs) == 1
                output1 = outputs[0]
                output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
                assert output1_content.strip() == f"Version {version}"

    @skip_without_tool("multiple_versions")
    def test_test_by_versions(self):
        test_data_response = self._get("tools/multiple_versions/test_data")
        test_data_response.raise_for_status()
        test_data_dicts = test_data_response.json()
        assert len(test_data_dicts) == 1
        assert test_data_dicts[0]["tool_version"] == "0.2"

        test_data_response = self._get("tools/multiple_versions/test_data?tool_version=*")
        test_data_response.raise_for_status()
        test_data_dicts = test_data_response.json()
        # this found a bug - tools that appear in the toolbox twice should not cause
        # multiple copies of test data to be returned. This assertion broke when
        # we placed multiple_versions in the test tool panel in multiple places. We need
        # to fix this but it isn't as important as the existing bug.
        # assert len(test_data_dicts) == 3

    @skip_without_tool("multiple_versions")
    def test_show_with_wrong_tool_version_in_tool_id(self):
        tool_info = self._show_valid_tool("multiple_versions", tool_version="0.01")
        # Return last version
        assert tool_info["version"] == "0.2"

    @skip_without_tool("cat1")
    def test_run_cat1_single_meta_wrapper(self):
        with self.dataset_populator.test_history_for(self.test_run_cat1_single_meta_wrapper) as history_id:
            # Wrap input in a no-op meta parameter wrapper like Sam is planning to
            # use for all UI API submissions.
            new_dataset = self.dataset_populator.new_dataset(history_id, content="123")
            inputs = dict(
                input1={"batch": False, "values": [dataset_to_param(new_dataset)]},
            )
            outputs = self._cat1_outputs(history_id, inputs=inputs)
            assert len(outputs) == 1
            output1 = outputs[0]
            output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
            assert output1_content.strip() == "123"

    @skip_without_tool("cat1")
    @requires_new_history
    def test_guess_derived_permissions(self):
        with self.dataset_populator.test_history_for(self.test_run_cat1_single_meta_wrapper) as history_id:

            def assert_inputs(inputs, can_be_used=True):
                # Until we make the dataset private, _different_user() can use it:
                with self._different_user_and_history() as other_history_id:
                    response = self._run("cat1", other_history_id, inputs)
                    if can_be_used:
                        assert response.status_code == 200
                    else:
                        self._assert_dataset_permission_denied_response(response)

            new_dataset = self.dataset_populator.new_dataset(history_id, content="Cat1Test")
            inputs = dict(
                input1=dataset_to_param(new_dataset),
            )
            # Until we make the dataset private, _different_user() can use it:
            assert_inputs(inputs, can_be_used=True)
            self.dataset_populator.make_private(history_id, new_dataset["id"])
            # _different_user can no longer use the input dataset.
            assert_inputs(inputs, can_be_used=False)

            outputs = self._cat1_outputs(history_id, inputs=inputs)
            assert len(outputs) == 1
            output1 = outputs[0]

            inputs_2 = dict(
                input1=dataset_to_param(output1),
            )
            # _different_user cannot use datasets derived from the private input.
            assert_inputs(inputs_2, can_be_used=False)

    @skip_without_tool("collection_creates_list")
    def test_guess_derived_permissions_collections(self, history_id):
        def first_element_dataset_id(hdca):
            # Fetch full and updated details for HDCA
            print(hdca)
            full_hdca = self.dataset_populator.get_history_collection_details(history_id, hid=hdca["hid"])
            elements = full_hdca["elements"]
            element0 = elements[0]["object"]
            return element0["id"]

        response = self.dataset_collection_populator.create_list_in_history(
            history_id, contents=["a\nb\nc\nd", "e\nf\ng\nh"], direct_upload=True, wait=True
        )
        self._assert_status_code_is(response, 200)
        hdca = response.json()["output_collections"][0]
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        inputs = {
            "input1": {"src": "hdca", "id": hdca["id"]},
        }
        public_output_response = self._run("collection_creates_list", history_id, inputs)
        self._assert_status_code_is(public_output_response, 200)
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)

        input_element_id = first_element_dataset_id(hdca)
        self.dataset_populator.make_private(history_id, input_element_id)

        private_output_response = self._run("collection_creates_list", history_id, inputs)
        self._assert_status_code_is(private_output_response, 200)
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)

        public_element_id = first_element_dataset_id(public_output_response.json()["output_collections"][0])
        private_element_id = first_element_dataset_id(private_output_response.json()["output_collections"][0])

        def _dataset_accessible(dataset_id):
            contents_response = self._get(f"histories/{history_id}/contents/{dataset_id}").json()
            return "name" in contents_response

        with self._different_user():
            assert _dataset_accessible(public_element_id)
            assert not _dataset_accessible(private_element_id)

    @skip_without_tool("validation_default")
    def test_validation(self, history_id):
        inputs = {
            "select_param": '" ; echo "moo',
        }
        response = self._run("validation_default", history_id, inputs)
        self._assert_status_code_is(response, 400)

    @skip_without_tool("validation_empty_dataset")
    def test_validation_empty_dataset(self, history_id):
        outputs = self._run_and_get_outputs("empty_output", history_id)
        empty_dataset = outputs[0]
        inputs = {
            "input1": dataset_to_param(empty_dataset),
        }
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        response = self._run("validation_empty_dataset", history_id, inputs)
        self._assert_status_code_is(response, 400)

    @skip_without_tool("validation_repeat")
    def test_validation_in_repeat(self, history_id):
        inputs = {
            "r1_0|text": "123",
            "r2_0|text": "",
        }
        response = self._run("validation_repeat", history_id, inputs)
        self._assert_status_code_is(response, 400)

    @skip_without_tool("multi_select")
    def test_select_legal_values(self, history_id):
        inputs = {
            "select_ex": "not_option",
        }
        response = self._run("multi_select", history_id, inputs)
        self._assert_status_code_is(response, 400)

    @skip_without_tool("column_param")
    def test_column_legal_values(self, history_id):
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="#col1\tcol2")
        inputs = {
            "input1": {"src": "hda", "id": new_dataset1["id"]},
            "col": "' ; echo 'moo",
        }
        response = self._run("column_param", history_id, inputs)
        # This needs to either fail at submit time or at job prepare time, but we have
        # to make sure the job doesn't run.
        if response.status_code == 200:
            job = response.json()["jobs"][0]
            final_job_state = self.dataset_populator.wait_for_job(job["id"])
            assert final_job_state == "error"

    @requires_new_history
    @skip_without_tool("collection_paired_test")
    def test_collection_parameter(self, history_id):
        hdca_id = self._build_pair(history_id, ["123\n", "456\n"])
        inputs = {
            "f1": {"src": "hdca", "id": hdca_id},
        }
        output = self._run("collection_paired_test", history_id, inputs, assert_ok=True)
        assert len(output["jobs"]) == 1
        assert len(output["implicit_collections"]) == 0
        assert len(output["outputs"]) == 1
        contents = self.dataset_populator.get_history_dataset_content(history_id, hid=4)
        assert contents.strip() == "123\n456", contents

    @skip_without_tool("collection_creates_pair")
    def test_paired_collection_output(self, history_id):
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="123\n456\n789\n0ab")
        inputs = {
            "input1": {"src": "hda", "id": new_dataset1["id"]},
        }
        # TODO: shouldn't need this wait
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        create = self._run("collection_creates_pair", history_id, inputs, assert_ok=True)
        output_collection = self._assert_one_job_one_collection_run(create)
        element0, element1 = self._assert_elements_are(output_collection, "forward", "reverse")
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        self._verify_element(history_id, element0, contents="123\n789\n", file_ext="txt", visible=False)
        self._verify_element(history_id, element1, contents="456\n0ab\n", file_ext="txt", visible=False)

    @skip_without_tool("collection_creates_list")
    def test_list_collection_output(self, history_id):
        create_response = self.dataset_collection_populator.create_list_in_history(
            history_id, contents=["a\nb\nc\nd", "e\nf\ng\nh"], wait=True
        )
        hdca_id = create_response.json()["outputs"][0]["id"]
        create = self.dataset_populator.run_collection_creates_list(history_id, hdca_id)
        output_collection = self._assert_one_job_one_collection_run(create)
        element0, element1 = self._assert_elements_are(output_collection, "data0", "data1")
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        self._verify_element(history_id, element0, contents="identifier is data0\n", file_ext="txt")
        self._verify_element(history_id, element1, contents="identifier is data1\n", file_ext="txt")

    @skip_without_tool("collection_creates_list_2")
    def test_list_collection_output_format_source(self, history_id):
        # test using format_source with a tool
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="#col1\tcol2")
        create_response = self.dataset_collection_populator.create_list_in_history(
            history_id, contents=["a\tb\nc\td", "e\tf\ng\th"], wait=True
        )
        hdca_id = create_response.json()["outputs"][0]["id"]
        inputs = {
            "header": {"src": "hda", "id": new_dataset1["id"]},
            "input_collect": {"src": "hdca", "id": hdca_id},
        }
        # TODO: real problem here - shouldn't have to have this wait.
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        create = self._run("collection_creates_list_2", history_id, inputs, assert_ok=True)
        output_collection = self._assert_one_job_one_collection_run(create)
        element0, element1 = self._assert_elements_are(output_collection, "data0", "data1")
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        self._verify_element(history_id, element0, contents="#col1\tcol2\na\tb\nc\td\n", file_ext="txt")
        self._verify_element(history_id, element1, contents="#col1\tcol2\ne\tf\ng\th\n", file_ext="txt")

    @skip_without_tool("collection_split_on_column")
    def test_dynamic_list_output(self, history_id):
        new_dataset1 = self.dataset_populator.new_dataset(
            history_id, content="samp1\t1\nsamp1\t3\nsamp2\t2\nsamp2\t4\n"
        )
        inputs = {
            "input1": dataset_to_param(new_dataset1),
        }
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        create = self._run("collection_split_on_column", history_id, inputs, assert_ok=True)

        output_collection = self._assert_one_job_one_collection_run(create)
        self._assert_has_keys(output_collection, "id", "name", "elements", "populated")
        assert not output_collection["populated"]
        assert len(output_collection["elements"]) == 0
        assert output_collection["name"] == "Table split on first column"
        self.dataset_populator.wait_for_job(create["jobs"][0]["id"], assert_ok=True)

        get_collection_response = self._get(
            f"dataset_collections/{output_collection['id']}", data={"instance_type": "history"}
        )
        self._assert_status_code_is(get_collection_response, 200)

        output_collection = get_collection_response.json()
        self._assert_has_keys(output_collection, "id", "name", "elements", "populated")
        assert output_collection["populated"]
        assert output_collection["name"] == "Table split on first column"

        assert len(output_collection["elements"]) == 2
        output_element_0 = output_collection["elements"][0]
        assert output_element_0["element_index"] == 0
        assert output_element_0["element_identifier"] == "samp1"
        output_element_hda_0 = output_element_0["object"]
        assert output_element_hda_0["metadata_column_types"] is not None

    @skip_without_tool("collection_creates_dynamic_nested")
    def test_dynamic_list_output_datasets_in_failed_state(self, history_id):
        inputs = {"fail_bool": True}
        create = self._run("collection_creates_dynamic_nested", history_id, inputs, assert_ok=False, wait_for_job=True)
        self._assert_status_code_is(create, 200)
        collection = self._get(
            f"dataset_collections/{create.json()['output_collections'][0]['id']}", data={"instance_type": "history"}
        ).json()
        assert collection["element_count"] == 3
        for nested_collection in collection["elements"]:
            nested_collection = nested_collection["object"]
            assert nested_collection["element_count"] == 2
            for element in nested_collection["elements"]:
                assert element["object"]["state"] == "error"

    def test_nonadmin_users_cannot_create_tools(self):
        payload = dict(
            representation=MINIMAL_TOOL,
        )
        create_response = self._post("dynamic_tools", data=payload, admin=False)
        self._assert_status_code_is(create_response, 403)

    def test_dynamic_tool_1(self):
        # Create tool.
        self.dataset_populator.create_tool(MINIMAL_TOOL)

        # Run tool.
        history_id = self.dataset_populator.new_history()
        self._run("minimal_tool", history_id)

        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        output_content = self.dataset_populator.get_history_dataset_content(history_id)
        assert output_content == "Hello World\n"

    def test_dynamic_tool_from_path(self):
        # Create tool.
        dynamic_tool_path = os.path.join(
            galaxy_root_path, "lib", "galaxy_test", "base", "data", "minimal_tool_no_id.json"
        )
        tool_response = self.dataset_populator.create_tool_from_path(dynamic_tool_path)
        self._assert_has_keys(tool_response, "uuid")

        # Run tool.
        history_id = self.dataset_populator.new_history()
        self._run(history_id=history_id, tool_uuid=tool_response["uuid"])

        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        output_content = self.dataset_populator.get_history_dataset_content(history_id)
        assert output_content == "Hello World 2\n"

    def test_dynamic_tool_no_id(self):
        # Create tool.
        tool_response = self.dataset_populator.create_tool(MINIMAL_TOOL_NO_ID)
        self._assert_has_keys(tool_response, "uuid")

        # Run tool.
        history_id = self.dataset_populator.new_history()
        self._run(history_id=history_id, tool_uuid=tool_response["uuid"], assert_ok=True)

        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        output_content = self.dataset_populator.get_history_dataset_content(history_id)
        assert output_content == "Hello World 2\n"

    # This works except I don't want to add it to the schema right now,
    # since I think the shell_command is what we'll go with (at least initially)
    # def test_dynamic_tool_base_command(self):
    #     TOOL_WITH_BASE_COMMAND = {
    #         "name": "Base command tool",
    #         "class": "GalaxyTool",
    #         "version": "1.0.0",
    #         "base_command": "cat",
    #         "arguments": ["$(inputs.input.path)", ">", "output.fastq"],
    #         "inputs": [
    #             {
    #                 "type": "data",
    #                 "name": "input",
    #             }
    #         ],
    #         "outputs": {
    #             "output": {
    #                 "type": "data",
    #                 "from_work_dir": "output.fastq",
    #                 "name": "output",
    #             }
    #         },
    #     }
    #     tool_response = self.dataset_populator.create_tool(TOOL_WITH_BASE_COMMAND)
    #     self._assert_has_keys(tool_response, "uuid")

    #     # Run tool.
    #     history_id = self.dataset_populator.new_history()
    #     dataset = self.dataset_populator.new_dataset(history_id=history_id, content="abc")
    #     self._run(
    #         history_id=history_id,
    #         tool_uuid=tool_response["uuid"],
    #         inputs={"input": {"src": "hda", "id": dataset["id"]}},
    #     )

    #     self.dataset_populator.wait_for_history(history_id, assert_ok=True)
    #     output_content = self.dataset_populator.get_history_dataset_content(history_id)
    #     assert output_content == "abc\n"

    def test_dynamic_tool_shell_command(self):
        tool_response = self.dataset_populator.create_tool(TOOL_WITH_SHELL_COMMAND)
        self._assert_has_keys(tool_response, "uuid")

        # Run tool.
        history_id = self.dataset_populator.new_history()
        dataset = self.dataset_populator.new_dataset(history_id=history_id, content="abc")
        self._run(
            history_id=history_id,
            tool_uuid=tool_response["uuid"],
            inputs={"input": {"src": "hda", "id": dataset["id"]}},
        )

        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        output_content = self.dataset_populator.get_history_dataset_content(history_id)
        assert output_content == "abc\n"

    @skip_without_tool("cat_multiple_user_defined")
    def test_collection_into_multiple_true(self):
        with (
            self.dataset_populator.test_history() as history_id,
            self.dataset_populator.user_tool_execute_permissions(),
        ):
            hdca = self.dataset_collection_populator.create_list_in_history(history_id, wait=True).json()[
                "output_collections"
            ][0]
            response = self._run(
                "cat_multiple_user_defined",
                history_id,
                {"datasets": {"values": [{"src": "hdca", "id": hdca["id"]}]}},
                assert_ok=True,
                wait_for_job=True,
            )
            output_content = self.dataset_populator.get_history_dataset_content(
                history_id, response["outputs"][0]["id"]
            )
            assert output_content == "TestData123\n" * 3

    def test_show_dynamic_tools(self):
        # Create tool.
        original_list = self.dataset_populator.list_dynamic_tools()
        created_dynamic_tool_dict = self.dataset_populator.create_tool(MINIMAL_TOOL_NO_ID)
        self._assert_has_keys(created_dynamic_tool_dict, "id", "uuid", "active")
        created_id = created_dynamic_tool_dict["id"]
        created_uuid = created_dynamic_tool_dict["uuid"]
        new_list = self.dataset_populator.list_dynamic_tools()

        for dynamic_tool_dict in original_list:
            self._assert_has_keys(dynamic_tool_dict, "id", "uuid", "active")
            assert dynamic_tool_dict["id"] != created_id
            assert dynamic_tool_dict["uuid"] != created_uuid

        found_id = False
        found_uuid = False
        for dynamic_tool_dict in new_list:
            self._assert_has_keys(dynamic_tool_dict, "id", "uuid", "active")
            found_id = found_id or dynamic_tool_dict["id"] == created_id
            found_uuid = found_uuid or dynamic_tool_dict["uuid"] == created_uuid

        assert found_id
        assert found_uuid

    def test_show_tool_source_admin(self):
        response = self._get("tools/cat1/raw_tool_source", admin=True)
        response.raise_for_status()
        assert "Concatenate multiple datasets" in response.text
        assert response.headers["language"] == "xml"

    def test_show_tool_source_denied(self):
        with self._different_user(anon=True):
            response = self._get("tools/cat1/raw_tool_source")
            assert response.status_code == 403

    def test_tool_deactivate(self):
        # Create tool.
        tool_response = self.dataset_populator.create_tool(MINIMAL_TOOL_NO_ID)
        self._assert_has_keys(tool_response, "id", "uuid", "active")
        assert tool_response["active"]
        deactivate_response = self.dataset_populator.deactivate_dynamic_tool(tool_response["uuid"])
        assert not deactivate_response["active"]

        # Run tool.
        history_id = self.dataset_populator.new_history()
        response = self._run(history_id=history_id, tool_uuid=tool_response["uuid"], assert_ok=False)
        # Get a 404 when trying to run a deactivated tool.
        self._assert_status_code_is(response, 404)

    @skip_without_tool("cat1")
    def test_run_cat1_with_two_inputs(self, history_id):
        # Run tool with an multiple data parameter and grouping (repeat)
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="Cat1Test")
        new_dataset2 = self.dataset_populator.new_dataset(history_id, content="Cat2Test")
        inputs = {"input1": dataset_to_param(new_dataset1), "queries_0|input2": dataset_to_param(new_dataset2)}
        outputs = self._cat1_outputs(history_id, inputs=inputs)
        assert len(outputs) == 1
        output1 = outputs[0]
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        assert output1_content.strip() == "Cat1Test\nCat2Test"

    @skip_without_tool("mapper_two")
    def test_bam_state_regression(self, history_id):
        # Test regression of https://github.com/galaxyproject/galaxy/issues/6856. With changes
        # to metadata file flushing to optimize creating bam outputs and copying bam datasets
        # we observed very subtle problems with HDA state changes on other files being flushed at
        # the same time. This tests txt datasets finalized before and after the bam outputs as
        # well as other bam files all flush properly during job completion.
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="123\n456\n789")
        inputs = {
            "input1": dataset_to_param(new_dataset1),
            "reference": dataset_to_param(new_dataset1),
        }
        outputs = self._run_and_get_outputs("mapper_two", history_id, inputs)
        assert len(outputs) == 4
        for output in outputs:
            details = self.dataset_populator.get_history_dataset_details(history_id, dataset=output)
            assert details["state"] == "ok"

    @skip_without_tool("qc_stdout")
    def test_qc_messages(self, history_id):
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="123\n456\n789")
        inputs = {
            "input1": dataset_to_param(new_dataset1),
            "quality": 3,
        }
        create = self._run("qc_stdout", history_id, inputs, wait_for_job=True, assert_ok=True)
        assert "jobs" in create, create
        job_id = create["jobs"][0]["id"]
        details = self.dataset_populator.get_job_details(job_id, full=True).json()
        assert "job_messages" in details, details
        # test autogenerated message (if regex defines no description attribute)
        qc_message = details["job_messages"][0]
        # assert qc_message["code_desc"] == "QC Metrics for Tool", qc_message
        assert qc_message["desc"] == "QC: Matched on Quality of sample is 30%."
        assert qc_message["match"] == "Quality of sample is 30%."
        assert qc_message["error_level"] == 1.1
        # test message generated from the description containing a reference to group defined in the regex
        qc_message = details["job_messages"][1]
        assert qc_message["desc"] == "QC: Sample quality 30"
        assert qc_message["match"] == "Quality of sample is 30%."
        assert qc_message["error_level"] == 1.1

    @skip_without_tool("cat1")
    def test_multirun_cat1(self, history_id):
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="123")
        new_dataset2 = self.dataset_populator.new_dataset(history_id, content="456")
        datasets = [dataset_to_param(new_dataset1), dataset_to_param(new_dataset2)]
        inputs = {
            "input1": {
                "batch": True,
                "values": datasets,
            },
        }
        self._check_cat1_multirun(history_id, inputs)

    def _check_cat1_multirun(self, history_id, inputs):
        outputs = self._cat1_outputs(history_id, inputs=inputs)
        assert len(outputs) == 2
        output1 = outputs[0]
        output2 = outputs[1]
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        output2_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output2)
        assert output1_content.strip() == "123"
        assert output2_content.strip() == "456"

    @skip_without_tool("random_lines1")
    def test_multirun_non_data_parameter(self, history_id):
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="123\n456\n789")
        inputs = {"input": dataset_to_param(new_dataset1), "num_lines": {"batch": True, "values": [1, 2, 3]}}
        outputs = self._run_and_get_outputs("random_lines1", history_id, inputs)
        # Assert we have three outputs with 1, 2, and 3 lines respectively.
        assert len(outputs) == 3
        outputs_contents = [
            self.dataset_populator.get_history_dataset_content(history_id, dataset=o).strip() for o in outputs
        ]
        assert sorted(len(c.split("\n")) for c in outputs_contents) == [1, 2, 3]

    @skip_without_tool("dbkey_output_action")
    def test_dynamic_parameter_error_handling(self):
        # Run test with valid index once, then supply invalid dbkey and invalid table
        # entry to ensure dynamic param errors are register.
        job_data_list = []

        def register_job_data(job_data):
            job_data_list.append(job_data)

        def tool_test_case_list(inputs, required_files) -> list[ValidToolTestDict]:
            return [
                {
                    "inputs": inputs,
                    "outputs": [],
                    "required_files": required_files,
                    "output_collections": [],
                    "test_index": 0,
                    "tool_version": "0.1.0",
                    "tool_id": "dbkey_output_action",
                    "error": False,
                }
            ]

        tool_test_dicts = tool_test_case_list(
            {
                "input": ["simple_line.txt"],
                "index": ["hg18_value"],
            },
            [["simple_line.txt", {"value": "simple_line.txt", "dbkey": "hg18"}]],
        )
        dynamic_param_error = None
        test_driver = self.driver_or_skip_test_if_remote()
        try:
            test_driver.run_tool_test("dbkey_output_action", _tool_test_dicts=tool_test_dicts)
        except Exception as e:
            dynamic_param_error = getattr(e, "dynamic_param_error", False)
        assert dynamic_param_error is None

        tool_test_dicts = tool_test_case_list(
            {
                "input": ["simple_line.txt"],
                "index": ["hg18_value"],
            },
            [["simple_line.txt", {"value": "simple_line.txt", "dbkey": "hgnot18"}]],
        )
        dynamic_param_error = None
        try:
            test_driver.run_tool_test("dbkey_output_action", _tool_test_dicts=tool_test_dicts)
        except Exception as e:
            dynamic_param_error = getattr(e, "dynamic_param_error", False)
        assert dynamic_param_error

        tool_test_dicts = tool_test_case_list(
            {
                "input": ["simple_line.txt"],
                "index": ["hgnot18"],
            },
            [["simple_line.txt", {"value": "simple_line.txt", "dbkey": "hg18"}]],
        )
        dynamic_param_error = None
        try:
            test_driver.run_tool_test(
                "dbkey_output_action", _tool_test_dicts=tool_test_dicts, register_job_data=register_job_data
            )
        except Exception as e:
            dynamic_param_error = getattr(e, "dynamic_param_error", False)
        assert dynamic_param_error
        assert len(job_data_list) == 1
        job_data = job_data_list[0]
        assert job_data["status"] == "error"
        job_data_list.clear()

        dynamic_param_error = None
        try:
            test_driver.run_tool_test(
                "dbkey_output_action",
                _tool_test_dicts=tool_test_dicts,
                skip_on_dynamic_param_errors=True,
                register_job_data=register_job_data,
            )
        except Exception as e:
            dynamic_param_error = getattr(e, "dynamic_param_error", False)
        assert dynamic_param_error
        assert len(job_data_list) == 1
        job_data = job_data_list[0]
        assert job_data["status"] == "skip"

    def _assert_one_job_one_collection_run(self, create):
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        collections = create["output_collections"]

        assert len(jobs) == 1
        assert len(implicit_collections) == 0
        assert len(collections) == 1

        output_collection = collections[0]
        return output_collection

    def _assert_elements_are(self, collection, *args):
        elements = collection["elements"]
        assert len(elements) == len(args)
        for index, element in enumerate(elements):
            arg = args[index]
            assert arg == element["element_identifier"]
        return elements

    def _verify_element(self, history_id, element, **props):
        object_id = element["object"]["id"]

        if "contents" in props:
            expected_contents = props["contents"]

            contents = self.dataset_populator.get_history_dataset_content(history_id, dataset_id=object_id)
            assert contents == expected_contents

            del props["contents"]

        if props:
            details = self.dataset_populator.get_history_dataset_details(history_id, dataset_id=object_id)
            for key, value in props.items():
                assert details[key] == value

    @skip_without_tool("output_filter_with_input")
    def test_map_over_with_output_filter_no_filtering(self, history_id):
        hdca_id = self.dataset_collection_populator.create_list_in_history(history_id, wait=True).json()["outputs"][0][
            "id"
        ]
        inputs = {
            "input_1": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
            "produce_out_1": "true",
            "filter_text_1": "foo",
        }
        create = self._run("output_filter_with_input", history_id, inputs).json()
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 3
        assert len(implicit_collections) == 3
        self._check_implicit_collection_populated(create)

    @skip_without_tool("output_filter_with_input_optional")
    def test_map_over_with_output_filter_on_optional_input(self, history_id):
        hdca_id = self.dataset_collection_populator.create_list_in_history(
            history_id, contents=["myinputs"], wait=True
        ).json()["outputs"][0]["id"]
        inputs = {
            "input_1": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
        }
        create = self._run("output_filter_with_input_optional", history_id, inputs).json()
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 1
        self.dataset_populator.wait_for_job(jobs[0]["id"], assert_ok=True)
        assert len(implicit_collections) == 1
        self._check_implicit_collection_populated(create)

    @skip_without_tool("output_filter_with_input")
    def test_map_over_with_output_filter_one_filtered(self, history_id):
        hdca_id = self.dataset_collection_populator.create_list_in_history(history_id, wait=True).json()["outputs"][0][
            "id"
        ]
        inputs = {
            "input_1": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
            "produce_out_1": "true",
            "filter_text_1": "bar",
        }
        create = self._run("output_filter_with_input", history_id, inputs).json()
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 3
        assert len(implicit_collections) == 2
        self._check_implicit_collection_populated(create)

    @skip_without_tool("Cut1")
    def test_map_over_with_complex_output_actions(self, history_id):
        hdca_id = self._bed_list(history_id)
        inputs = {
            "columnList": "c1,c2,c3,c4,c5",
            "delimiter": "T",
            "input": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
        }
        create = self._run("Cut1", history_id, inputs).json()
        outputs = create["outputs"]
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 2
        assert len(outputs) == 2
        assert len(implicit_collections) == 1
        output1 = outputs[0]
        output2 = outputs[1]
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        output2_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output2)
        assert output1_content.startswith("chr1")
        assert output2_content.startswith("chr1")

    @skip_without_tool("collection_creates_dynamic_list_of_pairs")
    def test_map_over_with_discovered_output_collection_elements(self, history_id):
        hdca_id = self.dataset_collection_populator.create_list_in_history(history_id, wait=True).json()["outputs"][0][
            "id"
        ]
        inputs = {"input": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]}}
        create = self._run("collection_creates_dynamic_list_of_pairs", history_id, inputs).json()
        implicit_collections = create["implicit_collections"]
        assert len(implicit_collections) == 1
        assert implicit_collections[0]["collection_type"] == "list:list:paired"
        assert implicit_collections[0]["elements"][0]["object"]["element_count"] is None
        self.dataset_populator.wait_for_job(create["jobs"][0]["id"], assert_ok=True)
        hdca = self._get(f"histories/{history_id}/contents/dataset_collections/{implicit_collections[0]['id']}").json()
        assert hdca["elements"][0]["object"]["elements"][0]["object"]["elements"][0]["element_identifier"] == "forward"

    def _bed_list(self, history_id):
        bed1_contents = open(self.get_filename("1.bed")).read()
        bed2_contents = open(self.get_filename("2.bed")).read()
        contents = [bed1_contents, bed2_contents]
        hdca = self.dataset_collection_populator.create_list_in_history(history_id, contents=contents, wait=True).json()
        return hdca["outputs"][0]["id"]

    @skip_without_tool("identifier_single")
    def test_identifier_in_map(self, history_id):
        hdca_id = self._build_pair(history_id, ["123", "456"])
        inputs = {
            "input1": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
        }
        create_response = self._run("identifier_single", history_id, inputs)
        self._assert_status_code_is(create_response, 200)
        create = create_response.json()
        outputs = create["outputs"]
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 2
        assert len(outputs) == 2
        assert len(implicit_collections) == 1
        output1 = outputs[0]
        output2 = outputs[1]
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        output2_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output2)
        assert output1_content.strip() == "forward"
        assert output2_content.strip() == "reverse"

    @skip_without_tool("identifier_single")
    def test_identifier_outside_map(self, history_id):
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="123", name="Plain HDA")
        inputs = {
            "input1": {"src": "hda", "id": new_dataset1["id"]},
        }
        create_response = self._run("identifier_single", history_id, inputs)
        self._assert_status_code_is(create_response, 200)
        create = create_response.json()
        outputs = create["outputs"]
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 1
        assert len(outputs) == 1
        assert len(implicit_collections) == 0
        output1 = outputs[0]
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        assert output1_content.strip() == "Plain HDA"

    @skip_without_tool("identifier_multiple")
    def test_list_selectable_in_multidata_input(self, history_id):
        self.dataset_collection_populator.create_list_in_history(history_id, contents=["123", "456"], wait=True)
        build = self.dataset_populator.build_tool_state("identifier_multiple", history_id)
        assert len(build["inputs"][0]["options"]["hdca"]) == 1

    @skip_without_tool("identifier_in_conditional")
    def test_identifier_map_over_input_in_conditional(self, history_id):
        # Run cat tool, so HDA names are different from element identifiers
        hdca_id = self._build_pair(history_id, ["123", "456"], run_cat=True)
        inputs = {
            "outer_cond|input1": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
            "outer_cond|multi_input": False,
        }
        execute = (
            self.dataset_populator.describe_tool_execution("identifier_in_conditional")
            .in_history(history_id)
            .with_inputs(inputs)
        )
        collection = execute.assert_has_n_jobs(2).assert_creates_implicit_collection(0)
        collection.assert_has_dataset_element("forward").with_contents_stripped("forward")
        collection.assert_has_dataset_element("reverse").with_contents_stripped("reverse")

    @skip_without_tool("identifier_multiple_in_conditional")
    def test_identifier_multiple_reduce_in_conditional(self, history_id):
        hdca_id = self._build_pair(history_id, ["123", "456"])
        inputs = {
            "outer_cond|inner_cond|input1": {"src": "hdca", "id": hdca_id},
        }
        create_response = self._run("identifier_multiple_in_conditional", history_id, inputs)
        self._assert_status_code_is(create_response, 200)
        create = create_response.json()
        outputs = create["outputs"]
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 1
        assert len(outputs) == 1
        assert len(implicit_collections) == 0
        output1 = outputs[0]
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        assert output1_content.strip() == "forward\nreverse"

    @skip_without_tool("cat1")
    def test_map_over_nested_collections(self, history_id):
        hdca_id = self.__build_nested_list(history_id)
        inputs = {
            "input1": {"batch": True, "values": [dict(src="hdca", id=hdca_id)]},
        }
        self._check_simple_cat1_over_nested_collections(history_id, inputs)

    @skip_without_tool("collection_paired_structured_like")
    def test_paired_input_map_over_nested_collections(self, history_id):
        hdca_id = self.__build_nested_list(history_id)
        inputs = {
            "input1": {"batch": True, "values": [dict(map_over_type="paired", src="hdca", id=hdca_id)]},
        }
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        create = self._run("collection_paired_structured_like", history_id, inputs, assert_ok=True)
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 2
        assert len(implicit_collections) == 1
        implicit_collection = implicit_collections[0]
        assert implicit_collection["collection_type"] == "list:paired", implicit_collection["collection_type"]
        outer_elements = implicit_collection["elements"]
        assert len(outer_elements) == 2

    @skip_without_tool("collection_paired_conditional_structured_like")
    def test_paired_input_conditional_map_over_nested_collections(self, history_id):
        hdca_id = self.__build_nested_list(history_id)
        inputs = {
            "cond|cond_param": "paired",
            "cond|input1": {"batch": True, "values": [dict(map_over_type="paired", src="hdca", id=hdca_id)]},
        }
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        create = self._run("collection_paired_conditional_structured_like", history_id, inputs, assert_ok=True)
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 2
        assert len(implicit_collections) == 1
        implicit_collection = implicit_collections[0]
        assert implicit_collection["collection_type"] == "list:paired", implicit_collection["collection_type"]
        outer_elements = implicit_collection["elements"]
        assert len(outer_elements) == 2

    def _check_simple_cat1_over_nested_collections(self, history_id, inputs):
        create = self._run_cat1(history_id, inputs=inputs, assert_ok=True)
        outputs = create["outputs"]
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 4
        assert len(outputs) == 4
        assert len(implicit_collections) == 1
        implicit_collection = implicit_collections[0]
        self._assert_has_keys(implicit_collection, "collection_type", "elements")
        assert implicit_collection["collection_type"] == "list:paired"
        assert len(implicit_collection["elements"]) == 2
        first_element, second_element = implicit_collection["elements"]
        assert first_element["element_identifier"] == "test0", first_element
        assert second_element["element_identifier"] == "test1", second_element

        first_object = first_element["object"]
        assert first_object["collection_type"] == "paired"
        assert len(first_object["elements"]) == 2
        first_object_forward_element = first_object["elements"][0]
        assert outputs[0]["id"] == first_object_forward_element["object"]["id"]

    @skip_without_tool("cat1")
    def test_map_over_two_collections(self, history_id):
        hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
        hdca2_id = self._build_pair(history_id, ["789\n", "0ab\n"])
        inputs = {
            "input1": {"batch": True, "values": [{"src": "hdca", "id": hdca1_id}]},
            "queries_0|input2": {"batch": True, "values": [{"src": "hdca", "id": hdca2_id}]},
        }
        self._check_map_cat1_over_two_collections(history_id, inputs)

    def _check_map_cat1_over_two_collections(self, history_id, inputs):
        response = self._run_cat1(history_id, inputs)
        self._assert_status_code_is(response, 200)
        response_object = response.json()
        outputs = response_object["outputs"]
        assert len(outputs) == 2
        output1 = outputs[0]
        output2 = outputs[1]
        self.dataset_populator.wait_for_history(history_id)
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        output2_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output2)
        assert output1_content.strip() == "123\n789"
        assert output2_content.strip() == "456\n0ab"

        assert len(response_object["jobs"]) == 2
        assert len(response_object["implicit_collections"]) == 1

    @skip_without_tool("cat1")
    def test_map_over_two_collections_unlinked(self, history_id):
        hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
        hdca2_id = self._build_pair(history_id, ["789\n", "0ab\n"])
        inputs = {
            "input1": {"batch": True, "linked": False, "values": [{"src": "hdca", "id": hdca1_id}]},
            "queries_0|input2": {"batch": True, "linked": False, "values": [{"src": "hdca", "id": hdca2_id}]},
        }
        response = self._run_cat1(history_id, inputs)
        self._assert_status_code_is(response, 200)
        response_object = response.json()
        outputs = response_object["outputs"]
        assert len(outputs) == 4

        assert len(response_object["jobs"]) == 4
        implicit_collections = response_object["implicit_collections"]
        assert len(implicit_collections) == 1
        implicit_collection = implicit_collections[0]
        assert implicit_collection["collection_type"] == "paired:paired"

        outer_elements = implicit_collection["elements"]
        assert len(outer_elements) == 2
        element0, element1 = outer_elements
        assert element0["element_identifier"] == "forward"
        assert element1["element_identifier"] == "reverse"

        elements0 = element0["object"]["elements"]
        elements1 = element1["object"]["elements"]

        assert len(elements0) == 2
        assert len(elements1) == 2

        element00, element01 = elements0
        assert element00["element_identifier"] == "forward"
        assert element01["element_identifier"] == "reverse"

        element10, element11 = elements1
        assert element10["element_identifier"] == "forward"
        assert element11["element_identifier"] == "reverse"

        expected_contents_list = [
            (element00, "123\n789\n"),
            (element01, "123\n0ab\n"),
            (element10, "456\n789\n"),
            (element11, "456\n0ab\n"),
        ]
        for element, expected_contents in expected_contents_list:
            dataset_id = element["object"]["id"]
            contents = self.dataset_populator.get_history_dataset_content(history_id, dataset_id=dataset_id)
            assert expected_contents == contents

    @skip_without_tool("cat1")
    def test_map_over_collected_and_individual_datasets(self, history_id):
        hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
        new_dataset1 = self.dataset_populator.new_dataset(history_id, content="789")
        new_dataset2 = self.dataset_populator.new_dataset(history_id, content="0ab")

        inputs = {
            "input1": {"batch": True, "values": [{"src": "hdca", "id": hdca1_id}]},
            "queries_0|input2": {
                "batch": True,
                "values": [dataset_to_param(new_dataset1), dataset_to_param(new_dataset2)],
            },
        }
        response = self._run_cat1(history_id, inputs)
        self._assert_status_code_is(response, 200)
        response_object = response.json()
        outputs = response_object["outputs"]
        assert len(outputs) == 2

        assert len(response_object["jobs"]) == 2
        assert len(response_object["implicit_collections"]) == 1

    @skip_without_tool("identifier_source")
    def test_default_identifier_source_map_over(self):
        with self.dataset_populator.test_history() as history_id:
            input_a_hdca_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=[("A", "A content")], wait=True
            ).json()["outputs"][0]["id"]
            input_b_hdca_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=[("B", "B content")], wait=True
            ).json()["outputs"][0]["id"]
            inputs = {
                "inputA": {"batch": True, "values": [dict(src="hdca", id=input_a_hdca_id)]},
                "inputB": {"batch": True, "values": [dict(src="hdca", id=input_b_hdca_id)]},
            }
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            create = self._run("identifier_source", history_id, inputs, assert_ok=True)
            for implicit_collection in create["implicit_collections"]:
                if implicit_collection["output_name"] == "outputA":
                    assert implicit_collection["elements"][0]["element_identifier"] == "A"
                else:
                    assert implicit_collection["elements"][0]["element_identifier"] == "B"

    @skip_without_tool("collection_creates_pair")
    def test_map_over_collection_output(self):
        with self.dataset_populator.test_history() as history_id:
            create_response = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=["a\nb\nc\nd", "e\nf\ng\nh"], wait=True
            )
            hdca_id = create_response.json()["outputs"][0]["id"]
            inputs = {
                "input1": {"batch": True, "values": [dict(src="hdca", id=hdca_id)]},
            }
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            create = self._run("collection_creates_pair", history_id, inputs, assert_ok=True)
            jobs = create["jobs"]
            implicit_collections = create["implicit_collections"]
            assert len(jobs) == 2
            assert len(implicit_collections) == 1
            implicit_collection = implicit_collections[0]
            assert implicit_collection["collection_type"] == "list:paired", implicit_collection
            outer_elements = implicit_collection["elements"]
            assert len(outer_elements) == 2
            element0, element1 = outer_elements
            assert element0["element_identifier"] == "data0"
            assert element1["element_identifier"] == "data1"

            pair0, pair1 = element0["object"], element1["object"]
            pair00, pair01 = pair0["elements"]
            pair10, pair11 = pair1["elements"]

            for pair in pair0, pair1:
                assert "collection_type" in pair, pair
                assert pair["collection_type"] == "paired", pair

            pair_ids = []
            for pair_element in pair00, pair01, pair10, pair11:
                assert "object" in pair_element
                pair_ids.append(pair_element["object"]["id"])

            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            expected_contents = [
                "a\nc\n",
                "b\nd\n",
                "e\ng\n",
                "f\nh\n",
            ]
            for i in range(4):
                contents = self.dataset_populator.get_history_dataset_content(history_id, dataset_id=pair_ids[i])
                assert expected_contents[i] == contents

    @skip_without_tool("cat1")
    def test_cannot_map_over_incompatible_collections(self):
        with self.dataset_populator.test_history() as history_id:
            hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
            hdca2_id = self.dataset_collection_populator.create_list_in_history(history_id).json()["outputs"][0]["id"]
            inputs = {
                "input1": {
                    "batch": True,
                    "values": [{"src": "hdca", "id": hdca1_id}],
                },
                "queries_0|input2": {
                    "batch": True,
                    "values": [{"src": "hdca", "id": hdca2_id}],
                },
            }
            run_response = self._run_cat1(history_id, inputs)
            # TODO: Fix this error checking once switch over to new API decorator
            # on server.
            assert run_response.status_code >= 400

    @skip_without_tool("__FILTER_FROM_FILE__")
    def test_map_over_collection_structured_like(self):
        with self.dataset_populator.test_history() as history_id:
            hdca_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=[("A", "A"), ("B", "B")], wait=True
            ).json()["outputs"][0]["id"]
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            inputs = {
                "input": {"values": [dict(src="hdca", id=hdca_id)]},
                "how|filter_source": {"batch": True, "values": [dict(src="hdca", id=hdca_id)]},
            }
            implicit_collections = self._run("__FILTER_FROM_FILE__", history_id, inputs, assert_ok=True)[
                "implicit_collections"
            ]
            discarded_collection, filtered_collection = implicit_collections
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            history_contents = self.dataset_populator._get_contents_request(history_id).json()
            # We should have a final collection count of 3 (2 nested collections, plus the input collection)
            new_collections = (
                len([c for c in history_contents if c["history_content_type"] == "dataset_collection"]) - 1
            )
            assert (
                new_collections == 2
            ), f"Expected to generate 4 new, filtered collections, but got {new_collections} collections"
            assert (
                filtered_collection["collection_type"] == discarded_collection["collection_type"] == "list:list"
            ), filtered_collection
            collection_details = self.dataset_populator.get_history_collection_details(
                history_id, hid=filtered_collection["hid"]
            )
            assert collection_details["element_count"] == 2
            first_collection_level = collection_details["elements"][0]
            assert first_collection_level["element_type"] == "dataset_collection"
            second_collection_level = first_collection_level["object"]
            assert second_collection_level["collection_type"] == "list"
            assert second_collection_level["elements"][0]["element_type"] == "hda"

    @skip_without_tool("collection_type_source")
    def test_map_over_collection_type_source(self):
        with self.dataset_populator.test_history() as history_id:
            hdca_id = self.dataset_collection_populator.create_list_in_history(
                history_id, contents=[("A", "A"), ("B", "B")], wait=True
            ).json()["outputs"][0]["id"]
            self.dataset_populator.wait_for_history(history_id, assert_ok=True)
            inputs = {
                "input_collect": {"values": [dict(src="hdca", id=hdca_id)]},
                "header": {"batch": True, "values": [dict(src="hdca", id=hdca_id)]},
            }
            self._run("collection_type_source", history_id, inputs, assert_ok=True, wait_for_job=True)
            collection_details = self.dataset_populator.get_history_collection_details(history_id, hid=4)
            assert collection_details["elements"][0]["object"]["elements"][0]["element_type"] == "hda"

    @skip_without_tool("multi_data_param")
    def test_reduce_collections_legacy(self):
        with self.dataset_populator.test_history() as history_id:
            hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
            hdca2_id = self.dataset_collection_populator.create_list_in_history(history_id, wait=True).json()[
                "outputs"
            ][0]["id"]
            inputs = {
                "f1": f"__collection_reduce__|{hdca1_id}",
                "f2": f"__collection_reduce__|{hdca2_id}",
            }
            self._check_simple_reduce_job(history_id, inputs)

    @skip_without_tool("multi_data_param")
    def test_reduce_collections(self):
        with self.dataset_populator.test_history() as history_id:
            hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
            hdca2_id = self.dataset_collection_populator.create_list_in_history(history_id, wait=True).json()[
                "outputs"
            ][0]["id"]
            inputs = {
                "f1": {"src": "hdca", "id": hdca1_id},
                "f2": {"src": "hdca", "id": hdca2_id},
            }
            self._check_simple_reduce_job(history_id, inputs)

    @skip_without_tool("multi_data_param")
    def test_implicit_reduce_with_mapping(self):
        with self.dataset_populator.test_history() as history_id:
            hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
            hdca2_id = self.dataset_collection_populator.create_list_of_list_in_history(history_id, wait=True).json()[
                "id"
            ]
            inputs = {
                "f1": {"src": "hdca", "id": hdca1_id},
                "f2": {
                    "batch": True,
                    "values": [{"src": "hdca", "map_over_type": "list", "id": hdca2_id}],
                },
            }
            create = self._run("multi_data_param", history_id, inputs, assert_ok=True)
            jobs = create["jobs"]
            implicit_collections = create["implicit_collections"]
            assert len(jobs) == 1
            assert len(implicit_collections) == 2
            output_hdca = self.dataset_populator.get_history_collection_details(
                history_id, hid=implicit_collections[0]["hid"]
            )
            assert output_hdca["collection_type"] == "list"

    @skip_without_tool("column_multi_param")
    def test_multi_param_column_nested_list(self):
        with self.dataset_populator.test_history() as history_id:
            hdca = self.dataset_collection_populator.create_list_of_list_in_history(
                history_id, ext="tabular", wait=True
            ).json()
            inputs = {
                "input1": {"src": "hdca", "id": hdca["id"]},
                # FIXME: integers don't work here
                "col": "1",
            }
            response = self._run("column_multi_param", history_id, inputs, assert_ok=True)
            self.dataset_populator.wait_for_job(job_id=response["jobs"][0]["id"], assert_ok=True)

    @skip_without_tool("column_multi_param")
    def test_multi_param_column_nested_list_fails_on_invalid_column(self):
        with self.dataset_populator.test_history() as history_id:
            hdca = self.dataset_collection_populator.create_list_of_list_in_history(
                history_id, ext="tabular", wait=True
            ).json()
            inputs = {
                "input1": {"src": "hdca", "id": hdca["id"]},
                "col": "10",
            }
            try:
                self._run("column_multi_param", history_id, inputs, assert_ok=True)
            except AssertionError as e:
                exception_raised = e
            assert exception_raised, "Expected invalid column selection to fail job"

    @skip_without_tool("Grep1")
    def test_implicit_conversion_input_dataset_tracking(self):
        with self.dataset_populator.test_history() as history_id:
            compressed_path = self.test_data_resolver.get_filename("1.fastqsanger.gz")
            with open(compressed_path, "rb") as fh:
                dataset = self.dataset_populator.new_dataset(
                    history_id, content=fh, file_type="fastqsanger.gz", wait=True
                )
            outputs = self._run(
                "Grep1", history_id=history_id, inputs={"input": {"src": "hda", "id": dataset["id"]}}, assert_ok=True
            )
            job_details = self.dataset_populator.get_job_details(outputs["jobs"][0]["id"], full=True).json()
            assert job_details["inputs"]["input"]["id"] != dataset["id"]
            converted_input = self.dataset_populator.get_history_dataset_details(
                history_id=history_id, content_id=job_details["inputs"]["input"]["id"]
            )
            assert converted_input["extension"] == "fastqsanger"

            outputs = self._run(
                "Grep1",
                history_id=history_id,
                inputs={"input": {"src": "hda", "id": dataset["id"]}},
                use_cached_job=True,
                wait_for_job=True,
                assert_ok=True,
            )
            job_details = self.dataset_populator.get_job_details(outputs["jobs"][0]["id"], full=True).json()
            assert job_details["copied_from_job_id"]

    @skip_without_tool("column_multi_param")
    def test_implicit_conversion_and_reduce(self):
        with self.dataset_populator.test_history() as history_id:
            self._run_implicit_collection_and_reduce(history_id=history_id, param="1")

    @skip_without_tool("column_multi_param")
    def test_implicit_conversion_and_reduce_invalid_param(self):
        with self.dataset_populator.test_history() as history_id:
            with pytest.raises(AssertionError):
                self._run_implicit_collection_and_reduce(history_id=history_id, param="X")
        details = self.dataset_populator.get_history_dataset_details(history_id=history_id, hid=3, assert_ok=False)
        assert details["state"] == "error"
        assert "Parameter 'col': an invalid option" in details["misc_info"]

    def _run_implicit_collection_and_reduce(self, history_id, param):
        fasta_path = self.test_data_resolver.get_filename("1.fasta")
        with open(fasta_path) as fasta_fh:
            fasta_content = fasta_fh.read()
            response = self.dataset_collection_populator.upload_collection(
                history_id,
                "list",
                elements=[
                    {
                        "name": "test0",
                        "src": "pasted",
                        "paste_content": fasta_content,
                        "ext": "fasta",
                    }
                ],
                wait=True,
            )
            details = self.dataset_populator.get_history_dataset_details(history_id, hid=2)
            assert details["extension"] == "fasta"
            self._assert_status_code_is(response, 200)
            hdca_id = response.json()["outputs"][0]["id"]
            inputs = {
                "input1": {"src": "hdca", "id": hdca_id},
                "col": param,
            }
            create = self._run("column_multi_param", history_id, inputs, assert_ok=True)
            jobs = create["jobs"]
            assert len(jobs) == 1
            content = self.dataset_populator.get_history_dataset_content(history_id, hid=3)
            assert content.strip() == "hg17", content

    @skip_without_tool("multi_data_repeat")
    def test_reduce_collections_in_repeat(self):
        with self.dataset_populator.test_history() as history_id:
            hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
            inputs = {
                "outer_repeat_0|f1": {"src": "hdca", "id": hdca1_id},
            }
            create = self._run("multi_data_repeat", history_id, inputs, assert_ok=True)
            outputs = create["outputs"]
            jobs = create["jobs"]
            assert len(jobs) == 1
            assert len(outputs) == 1
            output1 = outputs[0]
            output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
            assert output1_content.strip() == "123\n456", output1_content

    @skip_without_tool("multi_data_repeat")
    def test_reduce_collections_in_repeat_legacy(self):
        with self.dataset_populator.test_history() as history_id:
            hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
            inputs = {
                "outer_repeat_0|f1": f"__collection_reduce__|{hdca1_id}",
            }
            create = self._run("multi_data_repeat", history_id, inputs, assert_ok=True)
            outputs = create["outputs"]
            jobs = create["jobs"]
            assert len(jobs) == 1
            assert len(outputs) == 1
            output1 = outputs[0]
            output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
            assert output1_content.strip() == "123\n456", output1_content

    @skip_without_tool("multi_data_param")
    def test_reduce_multiple_lists_on_multi_data(self):
        with self.dataset_populator.test_history() as history_id:
            hdca1_id = self._build_pair(history_id, ["123\n", "456\n"])
            hdca2_id = self.dataset_collection_populator.create_list_in_history(history_id, wait=True).json()[
                "outputs"
            ][0]["id"]
            inputs = {
                "f1": [{"src": "hdca", "id": hdca1_id}, {"src": "hdca", "id": hdca2_id}],
                "f2": [{"src": "hdca", "id": hdca1_id}],
            }
            create = self._run("multi_data_param", history_id, inputs, assert_ok=True)
            outputs = create["outputs"]
            jobs = create["jobs"]
            assert len(jobs) == 1
            assert len(outputs) == 2
            output1, output2 = outputs
            output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
            output2_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output2)
            assert output1_content.strip() == "123\n456\nTestData123\nTestData123\nTestData123"
            assert output2_content.strip() == "123\n456"

    def _check_simple_reduce_job(self, history_id, inputs):
        create = self._run("multi_data_param", history_id, inputs, assert_ok=True)
        outputs = create["outputs"]
        jobs = create["jobs"]
        assert len(jobs) == 1
        assert len(outputs) == 2
        output1, output2 = outputs
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output1)
        output2_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output2)
        assert output1_content.strip() == "123\n456", output1_content
        assert len(output2_content.strip().split("\n")) == 3, output2_content

    def _check_implicit_collection_populated(self, run_response):
        implicit_collections = run_response["implicit_collections"]
        assert implicit_collections
        for implicit_collection in implicit_collections:
            assert implicit_collection["populated_state"] == "ok"

    def _cat1_outputs(self, history_id, inputs):
        return self._run_outputs(self._run_cat1(history_id, inputs))

    def _run_and_get_outputs(self, tool_id, history_id, inputs=None, tool_version=None):
        if inputs is None:
            inputs = {}
        return self._run_outputs(self._run(tool_id, history_id, inputs, tool_version=tool_version))

    def _run_outputs(self, create_response):
        self._assert_status_code_is(create_response, 200)
        return create_response.json()["outputs"]

    def _run_cat1(self, history_id, inputs, assert_ok=False, **kwargs):
        return self._run("cat1", history_id, inputs, assert_ok=assert_ok, **kwargs)

    def __tool_ids(self):
        index = self._get("tool_panels/default")
        tools_index = index.json()
        # In panels by default, so flatten out sections...
        tool_ids = []
        for id, tool_or_section in tools_index.items():
            if "tools" in tool_or_section:
                tool_ids.extend([t for t in tool_or_section["tools"] if isinstance(t, str)])
            else:
                tool_ids.append(id)

        return tool_ids

    @skip_without_tool("collection_cat_group_tag_multiple")
    def test_group_tag_selection(self, history_id):
        input_hdca_id = self.__build_group_list(history_id)
        inputs = {
            "input1": {"src": "hdca", "id": input_hdca_id},
            "group": "condition:treated",
        }
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        response = self._run("collection_cat_group_tag", history_id, inputs, assert_ok=True)
        outputs = response["outputs"]
        assert len(outputs) == 1
        output = outputs[0]
        output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output)
        assert output_content.strip() == "123\n456"

    @skip_without_tool("collection_cat_group_tag_multiple")
    def test_group_tag_selection_multiple(self, history_id):
        input_hdca_id = self.__build_group_list(history_id)
        inputs = {
            "input1": {"src": "hdca", "id": input_hdca_id},
            "groups": "condition:treated,type:single",
        }
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        response = self._run("collection_cat_group_tag_multiple", history_id, inputs, assert_ok=True)
        outputs = response["outputs"]
        assert len(outputs) == 1
        output = outputs[0]
        output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output)
        assert output_content.strip() == "123\n456\n456\n0ab"

    @skip_without_tool("cat1")
    def test_run_deferred_dataset_cached(self, history_id):
        content = uuid4().hex
        details = self.dataset_populator.create_deferred_hda_with_hash(history_id, content)
        self.dataset_populator.materialize_dataset_instance(history_id, id=details["id"])
        self.dataset_populator.wait_on_history_length(history_id, wait_on_history_length=2)
        materialized = self.dataset_populator.get_history_dataset_details(
            history_id, hid=2, assert_ok=False, wait=False
        )
        inputs = {
            "input1": dataset_to_param(materialized),
        }
        # Attempt reusing job, but dataset never materialized before
        response = self._run_cat1(history_id, inputs, wait_for_job=True, use_cached_job=True).json()
        job_details = self.dataset_populator.get_job_details(response["jobs"][0]["id"], full=True).json()
        assert job_details["state"] == "ok"
        assert not job_details["copied_from_job_id"]
        # new upload, same content
        new_dataset = self.dataset_populator.create_deferred_hda_with_hash(history_id, content)
        self.dataset_populator.materialize_dataset_instance(history_id, id=new_dataset["id"])
        self.dataset_populator.wait_on_history_length(history_id, wait_on_history_length=5)
        new_materialized_dataset = self.dataset_populator.get_history_dataset_details(
            history_id, hid=5, assert_ok=False, wait=False
        )
        inputs = {
            "input1": dataset_to_param(new_materialized_dataset),
        }
        # Attempt reusing job, dataset materialized before, so cache should kick in
        response = self._run_cat1(history_id, inputs, wait_for_job=True, use_cached_job=True).json()
        new_job_details = self.dataset_populator.get_job_details(response["jobs"][0]["id"], full=True).json()
        assert new_job_details["state"] == "ok"
        assert new_job_details["copied_from_job_id"] == job_details["id"]

    @skip_without_tool("pileup")
    def test_metadata_validator_on_deferred_input(self, history_id):
        url_1 = self.dataset_populator.base64_url_for_test_file("1.bam")
        deferred_bam_details = self.dataset_populator.create_deferred_hda(history_id, url_1, ext="bam")
        fasta1_contents = open(self.get_filename("1.fasta")).read()
        fasta = self.dataset_populator.new_dataset(history_id, content=fasta1_contents)
        inputs = {"input1": dataset_to_param(deferred_bam_details), "reference": dataset_to_param(fasta)}
        run_response = self.dataset_populator.run_tool(tool_id="pileup", inputs=inputs, history_id=history_id)
        self.dataset_populator.wait_for_job(run_response["jobs"][0]["id"], assert_ok=True)

    @pytest.mark.xfail
    @skip_without_tool("pileup")
    def test_metadata_validator_can_fail_on_deferred_input(self, history_id):
        # This test fails because we just skip the validator
        # Fixing this is a TODO
        url_1 = self.dataset_populator.base64_url_for_test_file("3unsorted.bam")
        deferred_bam_details = self.dataset_populator.create_deferred_hda(history_id, url_1, ext="unsorted.bam")
        fasta1_contents = open(self.get_filename("1.fasta")).read()
        fasta = self.dataset_populator.new_dataset(history_id, content=fasta1_contents)
        inputs = {"input1": dataset_to_param(deferred_bam_details), "reference": dataset_to_param(fasta)}
        run_response = self.dataset_populator.run_tool(tool_id="pileup", inputs=inputs, history_id=history_id)
        self.dataset_populator.wait_for_job(run_response["jobs"][0]["id"], assert_ok=False)
        job_id = run_response["jobs"][0]["id"]
        job_details = self.dataset_populator.get_job_details(job_id=job_id).json()
        assert job_details["state"] == "failed"

    @skip_without_tool("gx_allow_uri_if_protocol")
    def test_allow_uri_if_protocol_on_deferred_input(self, history_id):
        source_uri = "https://raw.githubusercontent.com/galaxyproject/galaxy/dev/test-data/simple_line.txt"
        deferred_hda = self.dataset_populator.create_deferred_hda(history_id, source_uri, ext="txt")

        inputs = {"input1": dataset_to_param(deferred_hda)}
        # The tool just returns the URI (or file path if it was materialized) as the output content
        run_response = self.dataset_populator.run_tool(
            tool_id="gx_allow_uri_if_protocol", inputs=inputs, history_id=history_id
        )
        output = run_response["outputs"][0]
        output_details = self.dataset_populator.get_history_dataset_details(
            history_id, dataset=output, wait=True, assert_ok=True
        )
        assert output_details["state"] == "ok"
        output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output)
        assert output_content.strip() == source_uri.strip()

    @skip_without_tool("gx_allow_uri_if_protocol")
    def test_allow_uri_if_protocol_on_collection_with_deferred(self, history_id):
        source_uris = [
            "https://raw.githubusercontent.com/galaxyproject/galaxy/dev/test-data/simple_line.txt",
            "https://raw.githubusercontent.com/galaxyproject/galaxy/dev/test-data/simple_line_alternative.txt",
        ]
        elements = [
            {
                "src": "url",
                "url": source_uri,
                "deferred": True,
                "ext": "txt",
            }
            for source_uri in source_uris
        ]
        targets = [
            {
                "destination": {"type": "hdca"},
                "elements": elements,
                "collection_type": "list",
                "name": "deferred list",
            }
        ]
        payload = {
            "history_id": history_id,
            "targets": json.dumps(targets),
        }
        fetch_response = self.dataset_populator.fetch(payload, wait=True)
        dataset_collection = self.dataset_collection_populator.wait_for_fetched_collection(fetch_response)
        hdca_id = dataset_collection["id"]
        inputs = {
            "input1": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
        }
        run_response = self.dataset_populator.run_tool(
            tool_id="gx_allow_uri_if_protocol", inputs=inputs, history_id=history_id
        )
        hdca_id = run_response["implicit_collections"][0]["id"]
        dataset_collection = self.dataset_populator.get_history_collection_details(history_id, id=hdca_id)
        elements = dataset_collection["elements"]
        assert len(elements) == 2
        for element in elements:
            object = element["object"]
            assert isinstance(object, dict)
            assert object["state"] == "ok"
            output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=object)
            assert output_content.strip() in source_uris

    @skip_without_tool("cat1")
    def test_run_deferred_mapping(self, history_id: str):
        url_1 = self.dataset_populator.base64_url_for_test_file("4.bed")
        elements = [
            {
                "src": "url",
                "url": url_1,
                "info": "my cool bed",
                "deferred": True,
                "ext": "bed",
            }
        ]
        targets = [
            {
                "destination": {"type": "hdca"},
                "elements": elements,
                "collection_type": "list",
                "name": "here is a name",
            }
        ]
        payload = {
            "history_id": history_id,
            "targets": json.dumps(targets),
        }
        fetch_response = self.dataset_populator.fetch(payload, wait=True)
        dataset_collection = self.dataset_collection_populator.wait_for_fetched_collection(fetch_response)
        hdca_id = dataset_collection["id"]
        inputs = {
            "input1": {"batch": True, "values": [{"src": "hdca", "id": hdca_id}]},
        }
        run_response = self._run_cat1(history_id, inputs).json()
        hdca_id = run_response["implicit_collections"][0]["id"]
        dataset_collection = self.dataset_populator.get_history_collection_details(history_id, id=hdca_id)
        elements = dataset_collection["elements"]
        assert len(elements) == 1
        object_0 = elements[0]["object"]
        assert isinstance(object_0, dict)
        assert object_0["state"] == "ok"
        output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=object_0)
        assert output_content.startswith("chr22	30128507	31828507	uc003bnx.1_cds_2_0_chr22_29227_f	0	+")

    @skip_without_tool("cat_list")
    def test_run_deferred_list_multi_data_reduction(self, history_id: str):
        url_1 = self.dataset_populator.base64_url_for_test_file("4.bed")
        elements = [
            {
                "src": "url",
                "url": url_1,
                "info": "my cool bed",
                "deferred": True,
                "ext": "bed",
            }
        ]
        targets = [
            {
                "destination": {"type": "hdca"},
                "elements": elements,
                "collection_type": "list",
                "name": "here is a name",
            }
        ]
        payload = {
            "history_id": history_id,
            "targets": json.dumps(targets),
        }
        fetch_response = self.dataset_populator.fetch(payload, wait=True)
        dataset_collection = self.dataset_collection_populator.wait_for_fetched_collection(fetch_response)
        hdca_id = dataset_collection["id"]
        inputs = {
            "input1": {"src": "hdca", "id": hdca_id},
        }
        run_response = self._run("cat_list", history_id, inputs, assert_ok=True)
        output_dataset = run_response["outputs"][0]
        output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output_dataset)
        assert output_content.startswith("chr22	30128507	31828507	uc003bnx.1_cds_2_0_chr22_29227_f	0	+")

    @skip_without_tool("cat_list")
    def test_run_deferred_nested_list_input(self, history_id: str):
        url_1 = self.dataset_populator.base64_url_for_test_file("4.bed")
        url_2 = self.dataset_populator.base64_url_for_test_file("1.bed")
        elements = [
            {
                "name": "outer",
                "elements": [
                    {
                        "src": "url",
                        "name": "forward",
                        "url": url_1,
                        "info": "my cool bed 4",
                        "deferred": True,
                        "ext": "bed",
                    },
                    {
                        "src": "url",
                        "name": "reverse",
                        "url": url_2,
                        "info": "my cool bed 1",
                        "deferred": True,
                        "ext": "bed",
                    },
                ],
            },
        ]
        targets = [
            {
                "destination": {"type": "hdca"},
                "elements": elements,
                "collection_type": "list:paired",
                "name": "here is a name",
            }
        ]
        payload = {
            "history_id": history_id,
            "targets": json.dumps(targets),
        }
        fetch_response = self.dataset_populator.fetch(payload, wait=True)
        dataset_collection = self.dataset_collection_populator.wait_for_fetched_collection(fetch_response)
        assert dataset_collection["collection_type"] == "list:paired"
        result_elements = dataset_collection["elements"]
        assert len(result_elements) == 1
        outer_element = result_elements[0]
        assert isinstance(outer_element, dict)
        inner_dataset_coll = outer_element["object"]
        assert isinstance(inner_dataset_coll, dict)
        inner_elements = inner_dataset_coll["elements"]
        assert isinstance(inner_elements, list)
        assert len(inner_elements) == 2
        hdca_id = dataset_collection["id"]
        inputs = {
            "input1": {"src": "hdca", "id": hdca_id},
        }
        run_response = self._run("collection_nested_test", history_id, inputs, assert_ok=True, wait_for_job=True)
        output_dataset = run_response["outputs"][1]
        output_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output_dataset)
        assert output_content.startswith("chr22	30128507	31828507	uc003bnx.1_cds_2_0_chr22_29227_f	0	+")

    @skip_without_tool("collection_paired_structured_like")
    def test_deferred_map_over_nested_collections(self, history_id):
        url_1 = self.dataset_populator.base64_url_for_test_file("4.bed")
        url_2 = self.dataset_populator.base64_url_for_test_file("1.bed")
        elements = [
            {
                "name": "outer1",
                "elements": [
                    {
                        "src": "url",
                        "name": "forward",
                        "url": url_1,
                        "info": "my cool bed 4",
                        "deferred": True,
                        "ext": "bed",
                    },
                    {
                        "src": "url",
                        "name": "reverse",
                        "url": url_2,
                        "info": "my cool bed 1",
                        "deferred": True,
                        "ext": "bed",
                    },
                ],
            },
        ]
        targets = [
            {
                "destination": {"type": "hdca"},
                "elements": elements,
                "collection_type": "list:paired",
                "name": "here is a name",
            }
        ]
        payload = {
            "history_id": history_id,
            "targets": json.dumps(targets),
        }
        fetch_response = self.dataset_populator.fetch(payload, wait=True)
        dataset_collection = self.dataset_collection_populator.wait_for_fetched_collection(fetch_response)
        hdca_id = dataset_collection["id"]
        inputs = {
            "input1": {"batch": True, "values": [dict(map_over_type="paired", src="hdca", id=hdca_id)]},
        }
        self.dataset_populator.wait_for_history(history_id, assert_ok=True)
        create = self._run("collection_paired_structured_like", history_id, inputs, assert_ok=True)
        jobs = create["jobs"]
        implicit_collections = create["implicit_collections"]
        assert len(jobs) == 1
        self.dataset_populator.wait_for_jobs(jobs, assert_ok=True)
        assert len(implicit_collections) == 1
        implicit_collection = implicit_collections[0]
        assert implicit_collection["collection_type"] == "list:paired", implicit_collection["collection_type"]
        outer_elements = implicit_collection["elements"]
        assert len(outer_elements) == 1
        element0 = outer_elements[0]
        pair1 = element0["object"]
        hda = pair1["elements"][0]["object"]
        output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=hda, wait=False)
        assert output1_content.startswith("chr22\t30128507\t31828507\tuc003bnx.1_cds_2_0_chr22_29227_f\t0\t+")

    def __build_group_list(self, history_id):
        response = self.dataset_collection_populator.upload_collection(
            history_id,
            "list",
            elements=[
                {
                    "name": "test0",
                    "src": "pasted",
                    "paste_content": "123\n",
                    "ext": "txt",
                    "tags": ["group:type:paired-end", "group:condition:treated"],
                },
                {
                    "name": "test1",
                    "src": "pasted",
                    "paste_content": "456\n",
                    "ext": "txt",
                    "tags": ["group:type:single", "group:condition:treated"],
                },
                {
                    "name": "test2",
                    "src": "pasted",
                    "paste_content": "789\n",
                    "ext": "txt",
                    "tags": ["group:type:paired-end", "group:condition:untreated"],
                },
                {
                    "name": "test3",
                    "src": "pasted",
                    "paste_content": "0ab\n",
                    "ext": "txt",
                    "tags": ["group:type:single", "group:condition:untreated"],
                },
            ],
            wait=True,
        )
        self._assert_status_code_is(response, 200)
        hdca_list_id = response.json()["outputs"][0]["id"]
        return hdca_list_id

    def __build_nested_list(self, history_id: str) -> str:
        return self.dataset_collection_populator.example_list_of_pairs(history_id)

    def _build_pair(self, history_id, contents, run_cat=False):
        create_response = self.dataset_collection_populator.create_pair_in_history(
            history_id, contents=contents, direct_upload=True, wait=True
        )
        hdca_id = create_response.json()["output_collections"][0]["id"]
        inputs = {
            "input1": {"batch": True, "values": [dict(src="hdca", id=hdca_id)]},
        }
        if run_cat:
            outputs = self._run_cat(history_id, inputs=inputs, assert_ok=True)
            hdca_id = outputs["implicit_collections"][0]["id"]
        return hdca_id

    def _assert_dataset_permission_denied_response(self, response):
        # TODO: This should be 403, should just need to throw more specific exception in the
        # Galaxy code.
        assert response.status_code != 200
        # err_message = response.json()["err_msg"]
        # assert "User does not have permission to use a dataset" in err_message, err_message

    @contextlib.contextmanager
    def _different_user_and_history(self, user_email: Optional[str] = None):
        with self._different_user(email=user_email):
            with self.dataset_populator.test_history() as other_history_id:
                yield other_history_id


def dataset_to_param(dataset):
    return dict(src="hda", id=dataset["id"])
