Commit e65ead8f authored by cjhopman's avatar cjhopman Committed by Commit bot

Make base_unittests_apk actually work

This changes the "test" template to create a shared_library (instead of
an executable) on Android. After this, we can actually run
base_unittests with
`build/android/test_runner.py gtest -s base_unnittests`
as normal (though may need to CHROMIUM_OUT_DIR and BUILDTYPE as
appropriate).

This requires adding the following targets:

//testing/android:native_test_native_code
//testing/android:native_test_util
//testing/android:native_test_jni_headers
//tools/android/md5sum:md5sum
//tools/android/md5sum:md5sum_bin
//tools/android/md5sum:md5sum_prepare_dist
//tools/android/md5sum:md5sum_copy_host

Also, makes it so that native executables are stripped (just like shared
libraries). Adds a simple create_native_execuatable_dist template that
sets up a dist directory for the executable (see
build/android/gyp/native_app_dependencies.gyp).

BUG=359249
TBR=rlarocque

Review URL: https://codereview.chromium.org/557463002

Cr-Commit-Position: refs/heads/master@{#294032}
parent e8dc2f1f
...@@ -1331,6 +1331,9 @@ test("base_unittests") { ...@@ -1331,6 +1331,9 @@ test("base_unittests") {
} }
if (is_android) { if (is_android) {
deps += [
"//testing/android:native_test_native_code",
]
set_sources_assignment_filter([]) set_sources_assignment_filter([])
sources += [ "debug/proc_maps_linux_unittest.cc" ] sources += [ "debug/proc_maps_linux_unittest.cc" ]
set_sources_assignment_filter(sources_assignment_filter) set_sources_assignment_filter(sources_assignment_filter)
......
...@@ -823,6 +823,7 @@ ...@@ -823,6 +823,7 @@
], ],
}, },
{ {
# GN: //base/test:test_support
'target_name': 'test_support_base', 'target_name': 'test_support_base',
'type': 'static_library', 'type': 'static_library',
'dependencies': [ 'dependencies': [
......
...@@ -8,6 +8,7 @@ if (is_android) { ...@@ -8,6 +8,7 @@ if (is_android) {
import("//build/config/android/rules.gni") import("//build/config/android/rules.gni")
} }
# GYP: //base/base.gyp:test_support_base
source_set("test_support") { source_set("test_support") {
# TODO http://crbug.com/412064 enable this flag all the time. # TODO http://crbug.com/412064 enable this flag all the time.
testonly = !is_component_build testonly = !is_component_build
......
...@@ -96,6 +96,7 @@ def GetSortedTransitiveDependenciesForBinaries(binaries): ...@@ -96,6 +96,7 @@ def GetSortedTransitiveDependenciesForBinaries(binaries):
def main(): def main():
parser = optparse.OptionParser() parser = optparse.OptionParser()
build_utils.AddDepfileOption(parser)
parser.add_option('--input-libraries', parser.add_option('--input-libraries',
help='A list of top-level input libraries.') help='A list of top-level input libraries.')
...@@ -126,6 +127,12 @@ def main(): ...@@ -126,6 +127,12 @@ def main():
if options.stamp: if options.stamp:
build_utils.Touch(options.stamp) build_utils.Touch(options.stamp)
if options.depfile:
print libraries
build_utils.WriteDepfile(
options.depfile,
libraries + build_utils.GetPythonDependencies())
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) sys.exit(main())
......
...@@ -294,12 +294,16 @@ def Setup(test_options, devices): ...@@ -294,12 +294,16 @@ def Setup(test_options, devices):
""" """
test_package = test_package_apk.TestPackageApk(test_options.suite_name) test_package = test_package_apk.TestPackageApk(test_options.suite_name)
if not os.path.exists(test_package.suite_path): if not os.path.exists(test_package.suite_path):
test_package = test_package_exe.TestPackageExecutable( exe_test_package = test_package_exe.TestPackageExecutable(
test_options.suite_name) test_options.suite_name)
if not os.path.exists(test_package.suite_path): if not os.path.exists(exe_test_package.suite_path):
raise Exception( raise Exception(
'Did not find %s target. Ensure it has been built.' 'Did not find %s target. Ensure it has been built.\n'
% test_options.suite_name) '(not found at %s or %s)'
% (test_options.suite_name,
test_package.suite_path,
exe_test_package.suite_path))
test_package = exe_test_package
logging.warning('Found target %s', test_package.suite_path) logging.warning('Found target %s', test_package.suite_path)
_GenerateDepsDirUsingIsolate(test_options.suite_name, _GenerateDepsDirUsingIsolate(test_options.suite_name,
......
...@@ -385,7 +385,7 @@ if (is_win) { ...@@ -385,7 +385,7 @@ if (is_win) {
] ]
} }
# Executable defaults (applies to executables and tests). # Executable defaults.
_executable_configs = _native_compiler_configs + [ _executable_configs = _native_compiler_configs + [
"//build/config:default_libs", "//build/config:default_libs",
] ]
...@@ -401,9 +401,6 @@ if (is_win) { ...@@ -401,9 +401,6 @@ if (is_win) {
set_defaults("executable") { set_defaults("executable") {
configs = _executable_configs configs = _executable_configs
} }
set_defaults("test") {
configs = _executable_configs
}
# Static library defaults. # Static library defaults.
set_defaults("static_library") { set_defaults("static_library") {
...@@ -438,6 +435,16 @@ if (!is_component_build) { ...@@ -438,6 +435,16 @@ if (!is_component_build) {
} }
} }
# Test defaults.
set_defaults("test") {
if (is_android) {
configs = _shared_library_configs
} else {
configs = _executable_configs
}
}
# ============================================================================== # ==============================================================================
# TOOLCHAIN SETUP # TOOLCHAIN SETUP
# ============================================================================== # ==============================================================================
...@@ -576,43 +583,80 @@ template("component") { ...@@ -576,43 +583,80 @@ template("component") {
# TEST SETUP # TEST SETUP
# ============================================================================== # ==============================================================================
# Define a test as an executable with the "testonly" flag set. In the future, # Define a test as an executable (or shared_library on Android) with the
# this will need to be enhanced for Android. # "testonly" flag set.
template("test") { template("test") {
executable(target_name) { if (is_android) {
# Configs will always be defined since we set_defaults for a component shared_library(target_name) {
# above. We want to use those rather than whatever came with the nested # Configs will always be defined since we set_defaults for a component
# shared/static library inside the component. # above. We want to use those rather than whatever came with the nested
configs = [] # Prevent list overwriting warning. # shared/static library inside the component.
configs = invoker.configs configs = [] # Prevent list overwriting warning.
configs = invoker.configs
testonly = true
# See above call.
# See above call. set_sources_assignment_filter([])
set_sources_assignment_filter([])
testonly = true
if (defined(invoker.all_dependent_configs)) { all_dependent_configs = invoker.all_dependent_configs }
if (defined(invoker.allow_circular_includes_from)) { allow_circular_includes_from = invoker.allow_circular_includes_from } if (defined(invoker.all_dependent_configs)) { all_dependent_configs = invoker.all_dependent_configs }
if (defined(invoker.cflags)) { cflags = invoker.cflags } if (defined(invoker.allow_circular_includes_from)) { allow_circular_includes_from = invoker.allow_circular_includes_from }
if (defined(invoker.cflags_c)) { cflags_c = invoker.cflags_c } if (defined(invoker.cflags)) { cflags = invoker.cflags }
if (defined(invoker.cflags_cc)) { cflags_cc = invoker.cflags_cc } if (defined(invoker.cflags_c)) { cflags_c = invoker.cflags_c }
if (defined(invoker.cflags_objc)) { cflags_objc = invoker.cflags_objc } if (defined(invoker.cflags_cc)) { cflags_cc = invoker.cflags_cc }
if (defined(invoker.cflags_objcc)) { cflags_objcc = invoker.cflags_objcc } if (defined(invoker.cflags_objc)) { cflags_objc = invoker.cflags_objc }
if (defined(invoker.check_includes)) { check_includes = invoker.check_includes } if (defined(invoker.cflags_objcc)) { cflags_objcc = invoker.cflags_objcc }
if (defined(invoker.data)) { data = invoker.data } if (defined(invoker.check_includes)) { check_includes = invoker.check_includes }
if (defined(invoker.datadeps)) { datadeps = invoker.datadeps } if (defined(invoker.data)) { data = invoker.data }
if (defined(invoker.defines)) { defines = invoker.defines } if (defined(invoker.datadeps)) { datadeps = invoker.datadeps }
if (defined(invoker.deps)) { deps = invoker.deps } if (defined(invoker.defines)) { defines = invoker.defines }
if (defined(invoker.direct_dependent_configs)) { direct_dependent_configs = invoker.direct_dependent_configs } if (defined(invoker.deps)) { deps = invoker.deps }
if (defined(invoker.forward_dependent_configs_from)) { forward_dependent_configs_from = invoker.forward_dependent_configs_from } if (defined(invoker.direct_dependent_configs)) { direct_dependent_configs = invoker.direct_dependent_configs }
if (defined(invoker.include_dirs)) { include_dirs = invoker.include_dirs } if (defined(invoker.forward_dependent_configs_from)) { forward_dependent_configs_from = invoker.forward_dependent_configs_from }
if (defined(invoker.ldflags)) { ldflags = invoker.ldflags } if (defined(invoker.include_dirs)) { include_dirs = invoker.include_dirs }
if (defined(invoker.lib_dirs)) { lib_dirs = invoker.lib_dirs } if (defined(invoker.ldflags)) { ldflags = invoker.ldflags }
if (defined(invoker.libs)) { libs = invoker.libs } if (defined(invoker.lib_dirs)) { lib_dirs = invoker.lib_dirs }
if (defined(invoker.output_extension)) { output_extension = invoker.output_extension } if (defined(invoker.libs)) { libs = invoker.libs }
if (defined(invoker.output_name)) { output_name = invoker.output_name } if (defined(invoker.output_extension)) { output_extension = invoker.output_extension }
if (defined(invoker.public)) { public = invoker.public } if (defined(invoker.output_name)) { output_name = invoker.output_name }
if (defined(invoker.sources)) { sources = invoker.sources } if (defined(invoker.public)) { public = invoker.public }
if (defined(invoker.visibility)) { visibility = invoker.visibility } if (defined(invoker.sources)) { sources = invoker.sources }
if (defined(invoker.visibility)) { visibility = invoker.visibility }
}
} else {
executable(target_name) {
# See above.
configs = [] # Prevent list overwriting warning.
configs = invoker.configs
# See above call.
set_sources_assignment_filter([])
testonly = true
if (defined(invoker.all_dependent_configs)) { all_dependent_configs = invoker.all_dependent_configs }
if (defined(invoker.allow_circular_includes_from)) { allow_circular_includes_from = invoker.allow_circular_includes_from }
if (defined(invoker.cflags)) { cflags = invoker.cflags }
if (defined(invoker.cflags_c)) { cflags_c = invoker.cflags_c }
if (defined(invoker.cflags_cc)) { cflags_cc = invoker.cflags_cc }
if (defined(invoker.cflags_objc)) { cflags_objc = invoker.cflags_objc }
if (defined(invoker.cflags_objcc)) { cflags_objcc = invoker.cflags_objcc }
if (defined(invoker.check_includes)) { check_includes = invoker.check_includes }
if (defined(invoker.data)) { data = invoker.data }
if (defined(invoker.datadeps)) { datadeps = invoker.datadeps }
if (defined(invoker.defines)) { defines = invoker.defines }
if (defined(invoker.deps)) { deps = invoker.deps }
if (defined(invoker.direct_dependent_configs)) { direct_dependent_configs = invoker.direct_dependent_configs }
if (defined(invoker.forward_dependent_configs_from)) { forward_dependent_configs_from = invoker.forward_dependent_configs_from }
if (defined(invoker.include_dirs)) { include_dirs = invoker.include_dirs }
if (defined(invoker.ldflags)) { ldflags = invoker.ldflags }
if (defined(invoker.lib_dirs)) { lib_dirs = invoker.lib_dirs }
if (defined(invoker.libs)) { libs = invoker.libs }
if (defined(invoker.output_extension)) { output_extension = invoker.output_extension }
if (defined(invoker.output_name)) { output_name = invoker.output_name }
if (defined(invoker.public)) { public = invoker.public }
if (defined(invoker.sources)) { sources = invoker.sources }
if (defined(invoker.visibility)) { visibility = invoker.visibility }
}
} }
} }
...@@ -856,7 +856,7 @@ template("unittest_apk") { ...@@ -856,7 +856,7 @@ template("unittest_apk") {
if (defined(invoker.unittests_binary)) { if (defined(invoker.unittests_binary)) {
unittests_binary = root_out_dir + "/" + invoker.unittests_binary unittests_binary = root_out_dir + "/" + invoker.unittests_binary
} else { } else {
unittests_binary = root_out_dir + "/" + test_suite_name unittests_binary = root_out_dir + "/lib.stripped/lib" + test_suite_name + ".so"
} }
android_apk(target_name) { android_apk(target_name) {
...@@ -871,6 +871,9 @@ template("unittest_apk") { ...@@ -871,6 +871,9 @@ template("unittest_apk") {
if (defined(invoker.deps)) { if (defined(invoker.deps)) {
deps = invoker.deps deps = invoker.deps
} }
datadeps = [
"//tools/android/md5sum",
]
testonly = true testonly = true
} }
} }
...@@ -944,3 +947,80 @@ template("android_aidl") { ...@@ -944,3 +947,80 @@ template("android_aidl") {
args += rebase_path(sources, root_build_dir) args += rebase_path(sources, root_build_dir)
} }
} }
# Creates a dist directory for a native executable.
#
# Running a native executable on a device requires all the shared library
# dependencies of that executable. To make it easier to install and run such an
# executable, this will create a directory containing the native exe and all
# it's library dependencies.
#
# Note: It's usually better to package things as an APK than as a native
# executable.
#
# Variables
# dist_dir: Directory for the exe and libraries. Everything in this directory
# will be deleted before copying in the exe and libraries.
# binary: Path to (stripped) executable.
#
# Example
# create_native_executable_dist("foo_dist") {
# dist_dir = "$root_build_dir/foo_dist"
# binary = "$root_build_dir/exe.stripped/foo"
# }
template("create_native_executable_dist") {
dist_dir = invoker.dist_dir
binary = invoker.binary
final_deps = []
template_name = target_name
libraries_list = "${target_gen_dir}/${template_name}_library_dependencies.list"
# TODO(gyp)
#'dependencies': [
#'<(DEPTH)/build/android/setup.gyp:copy_system_libraries',
#],
stripped_libraries_dir = "$root_build_dir/lib.stripped"
final_deps += [ ":${template_name}__find_library_dependencies" ]
action("${template_name}__find_library_dependencies") {
script = "//build/android/gyp/write_ordered_libraries.py"
depfile = "$target_gen_dir/$target_name.d"
inputs = [
binary,
android_readelf,
]
outputs = [
depfile,
libraries_list,
]
rebased_binaries = rebase_path([ binary ], root_build_dir)
args = [
"--depfile", rebase_path(depfile, root_build_dir),
"--input-libraries=$rebased_binaries",
"--libraries-dir", rebase_path(stripped_libraries_dir, root_build_dir),
"--output", rebase_path(libraries_list, root_build_dir),
"--readelf", rebase_path(android_readelf, root_build_dir),
]
}
final_deps += [ ":${template_name}__copy_libraries_and_exe" ]
copy_ex("${template_name}__copy_libraries_and_exe") {
clear_dir = true
inputs = [
binary,
libraries_list
]
dest = dist_dir
rebased_binaries_list = rebase_path([ binary ], root_build_dir)
rebased_libraries_list = rebase_path(libraries_list, root_build_dir)
args = [
"--files=$rebased_binaries_list",
"--files=@FileArg($rebased_libraries_list:libraries)",
]
}
group(target_name) {
deps = final_deps
}
}
...@@ -57,10 +57,21 @@ template("android_gcc_toolchain") { ...@@ -57,10 +57,21 @@ template("android_gcc_toolchain") {
temp_stripped_soname = "${stripped_soname}.tmp" temp_stripped_soname = "${stripped_soname}.tmp"
android_strip = "${tool_prefix}strip" android_strip = "${tool_prefix}strip"
mkdir_command = "mkdir -p lib.stripped" mkdir_command = "mkdir -p lib.stripped"
strip_command = "$android_strip --strip-unneeded -o $temp_stripped_soname $soname" strip_command = "$android_strip --strip-unneeded -o $temp_stripped_soname $soname"
replace_command = "if ! cmp -s $temp_stripped_soname $stripped_soname; then mv $temp_stripped_soname $stripped_soname; fi" replace_command = "if ! cmp -s $temp_stripped_soname $stripped_soname; then mv $temp_stripped_soname $stripped_soname; fi"
postsolink = "$mkdir_command && $strip_command && $replace_command" postsolink = "$mkdir_command && $strip_command && $replace_command"
solink_outputs = [ stripped_soname ]
# We make the assumption that the gcc_toolchain will produce an exe with
# the following definition.
exe = "{{root_out_dir}}/{{target_output_name}}{{output_extension}}"
stripped_exe = "exe.stripped/$exe"
mkdir_command = "mkdir -p exe.stripped"
strip_command = "$android_strip --strip-unneeded -o $stripped_exe $exe"
postlink = "$mkdir_command && $strip_command"
link_outputs = [ stripped_exe ]
} }
} }
......
...@@ -166,6 +166,9 @@ template("gcc_toolchain") { ...@@ -166,6 +166,9 @@ template("gcc_toolchain") {
sofile, sofile,
tocfile, tocfile,
] ]
if (defined(invoker.solink_outputs)) {
outputs += invoker.solink_outputs
}
link_output = sofile link_output = sofile
depend_output = tocfile depend_output = tocfile
} }
...@@ -174,9 +177,15 @@ template("gcc_toolchain") { ...@@ -174,9 +177,15 @@ template("gcc_toolchain") {
outfile = "{{root_out_dir}}/{{target_output_name}}{{output_extension}}" outfile = "{{root_out_dir}}/{{target_output_name}}{{output_extension}}"
rspfile = "$outfile.rsp" rspfile = "$outfile.rsp"
command = "$ld {{ldflags}} -o $outfile -Wl,--start-group @$rspfile {{solibs}} -Wl,--end-group $libs_section_prefix {{libs}} $libs_section_postfix" command = "$ld {{ldflags}} -o $outfile -Wl,--start-group @$rspfile {{solibs}} -Wl,--end-group $libs_section_prefix {{libs}} $libs_section_postfix"
if (defined(invoker.postlink)) {
command += " && " + invoker.postlink
}
description = "LINK $outfile" description = "LINK $outfile"
rspfile_content = "{{inputs}}" rspfile_content = "{{inputs}}"
outputs = [ outfile ] outputs = [ outfile ]
if (defined(invoker.link_outputs)) {
outputs += invoker.link_outputs
}
} }
tool("stamp") { tool("stamp") {
......
...@@ -6,7 +6,6 @@ ...@@ -6,7 +6,6 @@
#include "base/json/json_writer.h" #include "base/json/json_writer.h"
#include "base/values.h" #include "base/values.h"
#include "sync/test/accounts_client/test_accounts_client.cc"
#include "sync/test/accounts_client/test_accounts_client.h" #include "sync/test/accounts_client/test_accounts_client.h"
#include "testing/gmock/include/gmock/gmock.h" #include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h" #include "testing/gtest/include/gtest/gtest.h"
......
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import("//build/config/android/rules.gni")
# GYP: //testing/android/native_test.gyp:native_test_native_code
source_set("native_test_native_code") {
testonly = true
sources = [
"native_test_launcher.cc"
]
deps = [
":native_test_jni_headers",
":native_test_util",
"//base",
"//base/test:test_support",
"//base/third_party/dynamic_annotations",
"//testing/gtest",
]
}
# GYP: //testing/android/native_test.gyp:native_test_jni_headers
generate_jni("native_test_jni_headers") {
sources = [
"java/src/org/chromium/native_test/ChromeNativeTestActivity.java",
]
jni_package = "testing"
}
# GYP: //testing/android/native_test.gyp:native_test_util
source_set("native_test_util") {
sources = [
"native_test_util.cc",
"native_test_util.h",
]
deps = [
"//base"
]
}
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
['OS=="android"', { ['OS=="android"', {
'targets': [ 'targets': [
{ {
# GN: //testing/android:native_test_native_code
'target_name': 'native_test_native_code', 'target_name': 'native_test_native_code',
'message': 'building native pieces of native test package', 'message': 'building native pieces of native test package',
'type': 'static_library', 'type': 'static_library',
...@@ -30,6 +31,7 @@ ...@@ -30,6 +31,7 @@
], ],
}, },
{ {
# GN: //testing/android:native_test_jni_headers
'target_name': 'native_test_jni_headers', 'target_name': 'native_test_jni_headers',
'type': 'none', 'type': 'none',
'sources': [ 'sources': [
...@@ -41,6 +43,7 @@ ...@@ -41,6 +43,7 @@
'includes': [ '../../build/jni_generator.gypi' ], 'includes': [ '../../build/jni_generator.gypi' ],
}, },
{ {
# GN: //testing/android:native_test_util
'target_name': 'native_test_util', 'target_name': 'native_test_util',
'type': 'static_library', 'type': 'static_library',
'sources': [ 'sources': [
......
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# GYP: //tools/android/md5sum/md5sum.gyp:md5sum
group("md5sum") {
datadeps = [
":md5sum_bin($host_toolchain)",
":md5sum_bin($default_toolchain)",
":md5sum_prepare_dist($default_toolchain)",
":md5sum_copy_host($host_toolchain)",
]
# TODO(cjhopman): Remove once group datadeps are fixed.
deps = datadeps
}
# GYP: //tools/android/md5sum/md5sum.gyp:md5sum_bin_device (and md5sum_bin_host)
executable("md5sum_bin") {
sources = [
"md5sum.cc"
]
deps = [
"//base"
]
# TODO(GYP)
#'conditions': [
#[ 'order_profiling!=0 and OS=="android"', {
#'dependencies': [ '../../../tools/cygprofile/cygprofile.gyp:cygprofile', ],
#}],
#],
}
if (current_toolchain == default_toolchain) {
import("//build/config/android/rules.gni")
# GYP: //tools/android/md5sum/md5sum.gyp:md5sum_stripped_device_bin
create_native_executable_dist("md5sum_prepare_dist") {
dist_dir = "$root_build_dir/md5sum_dist"
binary = "$root_build_dir/exe.stripped/md5sum_bin"
}
} else {
# GYP: //tools/android/md5sum/md5sum.gyp:md5sum_bin_host
copy("md5sum_copy_host") {
sources = [
"$root_out_dir/md5sum_bin"
]
outputs = [
"$root_build_dir/md5sum_bin_host"
]
}
}
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
{ {
'targets': [ 'targets': [
{ {
# GN: //tools/android/md5sum:md5sum
'target_name': 'md5sum', 'target_name': 'md5sum',
'type': 'none', 'type': 'none',
'dependencies': [ 'dependencies': [
...@@ -20,6 +21,7 @@ ...@@ -20,6 +21,7 @@
'includes': ['../../../build/android/native_app_dependencies.gypi'], 'includes': ['../../../build/android/native_app_dependencies.gypi'],
}, },
{ {
# GN: //tools/android/md5sum:md5sum_bin($default_toolchain)
'target_name': 'md5sum_device_bin', 'target_name': 'md5sum_device_bin',
'type': 'executable', 'type': 'executable',
'dependencies': [ 'dependencies': [
...@@ -38,6 +40,7 @@ ...@@ -38,6 +40,7 @@
], ],
}, },
{ {
# GN: //tools/android/md5sum:md5sum_prepare_dist
'target_name': 'md5sum_stripped_device_bin', 'target_name': 'md5sum_stripped_device_bin',
'type': 'none', 'type': 'none',
'dependencies': [ 'dependencies': [
...@@ -60,6 +63,7 @@ ...@@ -60,6 +63,7 @@
}, },
# Same binary but for the host rather than the device. # Same binary but for the host rather than the device.
{ {
# GN: //tools/android/md5sum:md5sum_copy_host($default_toolchain)
'target_name': 'md5sum_bin_host', 'target_name': 'md5sum_bin_host',
'toolsets': ['host'], 'toolsets': ['host'],
'type': 'executable', 'type': 'executable',
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment