blob: 51ca3b17b4c6c8afe50f96bfbd6967bf488fc8fe [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2019 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import ast
import argparse
import functools
import glob
import json
import logging
import os
import re
import shlex
import subprocess
import sys
import tempfile
import time
import xml.sax
@functools.lru_cache(1)
def get_chromium_root():
path = os.path.realpath('../../../../')
assert os.path.basename(path) == 'src'
return path
def shell_join(cmd):
return ' '.join(shlex.quote(c) for c in cmd)
def run(args, cwd=None):
logging.debug(f'$ {shell_join(args)}')
subprocess.check_call(args, cwd=cwd)
def check_output(args, cwd=None):
logging.debug(f'$ {shell_join(args)}')
return subprocess.check_output(args, cwd=cwd, text=True)
def run_node(args):
root = get_chromium_root()
node = os.path.join(root, 'third_party/node/linux/node-linux-x64/bin/node')
binary = os.path.join(root, 'third_party/node/node_modules', args[0])
run([node, binary] + args[1:])
def build_preload_images_js(outdir):
with open('images/images.gni') as f:
in_app_images = ast.literal_eval(
re.search(r'in_app_images\s*=\s*(\[.*?\])', f.read(),
re.DOTALL).group(1))
preload_images_js_path = os.path.join(outdir, 'preload_images.js')
if os.path.exists(preload_images_js_path):
with open(preload_images_js_path) as f:
preload_images_js = f.read()
else:
preload_images_js = None
with tempfile.NamedTemporaryFile('w') as f:
f.writelines(
os.path.abspath(f'images/{asset}') + '\n'
for asset in in_app_images)
f.flush()
with tempfile.NamedTemporaryFile('r') as temp_file:
cmd = [
'utils/gen_preload_images_js.py',
'--images_list_file',
f.name,
'--output_file',
temp_file.name,
]
run(cmd)
new_preload_images_js = temp_file.read()
# Only write when the generated preload_images.js changes, to avoid
# changing mtime of the preload_images.js file when the images are
# not changed, so rsync won't copy the file again on deploy.
if new_preload_images_js == preload_images_js:
return
with open(preload_images_js_path, 'w') as output_file:
output_file.write(new_preload_images_js)
CCA_OVERRIDE_PATH = '/etc/camera/cca'
CCA_OVERRIDE_FEATURE = 'CCALocalOverride'
CHROME_DEV_CONF_PATH = '/etc/chrome_dev.conf'
def local_override_enabled(device):
chrome_dev_conf = check_output(
['ssh', device, '--', 'cat', CHROME_DEV_CONF_PATH])
# This is a simple heuristic that is not 100% accurate, since this only
# matches the feature name which can be in other irrevelant position in the
# file. This should be fine though since this is only used for developers
# and it's not expected to have the exact string match outside of
# --enable-features added by this script.
return CCA_OVERRIDE_FEATURE in chrome_dev_conf
def ensure_local_override_enabled(device, force):
if local_override_enabled(device):
return
run([
'ssh', device, '--',
f'echo "--enable-features={CCA_OVERRIDE_FEATURE}"' +
f' >> {CHROME_DEV_CONF_PATH}'
])
if not force:
prompt = input('Need to restart UI for deploy to take effect, ' +
'do it now? (y/N): ').lower()
if prompt != 'y':
print(
'Not restarting UI. ' +
'`restart ui` on DUT manually for the change to take effect.')
return
run(['ssh', device, '--', 'restart', 'ui'])
def get_tsc_paths(board):
root_dir = get_chromium_root()
target_gen_dir = os.path.join(root_dir, f'out_{board}/Release/gen')
resources_dir = os.path.join(target_gen_dir, 'ui/webui/resources/tsc/*')
lit_d_ts = os.path.join(
root_dir, 'third_party/material_web_components/lit_exports.d.ts')
return {
'//resources/*': [os.path.relpath(resources_dir)],
'chrome://resources/*': [os.path.relpath(resources_dir)],
'chrome://resources/mwc/lit/index.js': [os.path.relpath(lit_d_ts)],
}
def make_mojom_symlink(board):
cca_root = os.getcwd()
root_dir = get_chromium_root()
target_gen_dir = os.path.join(root_dir, f'out_{board}/Release/gen')
src_relative_dir = os.path.relpath(cca_root, root_dir)
generated_mojom_dir = os.path.join(target_gen_dir, src_relative_dir,
'mojom')
target = os.path.join(cca_root, 'mojom')
if os.path.islink(target):
if os.readlink(target) != generated_mojom_dir:
# There's a symlink here that's not pointing to the correct path.
# This might happen when changing board. Remove the symlink and
# recreate in this case.
os.remove(target)
os.symlink(generated_mojom_dir, target)
elif os.path.exists(target):
# Some other things are at the mojom path. cca.py won't work in
# this case.
raise Exception("resources/mojom exists but not a symlink."
" Please remove it and try again.")
else:
os.symlink(generated_mojom_dir, target)
def get_tsc_references(board):
root_dir = get_chromium_root()
target_gen_dir = os.path.join(root_dir, f'out_{board}/Release/gen')
mwc_tsconfig_path = os.path.join(
target_gen_dir,
'third_party/material_web_components/tsconfig_library.json')
return [{'path': os.path.relpath(mwc_tsconfig_path)}]
def generate_tsconfig(board):
cca_root = os.getcwd()
# TODO(pihsun): This needs to be in sync with BUILD.gn, have some heuristic
# to get the dependency from there or from the generated tsconfig.json
# instead?
root_dir = get_chromium_root()
common_definitions = os.path.join(root_dir, 'tools/typescript/definitions')
target_gen_dir = os.path.join(root_dir, f'out_{board}/Release/gen')
assert os.path.exists(target_gen_dir), (
f"Failed to find the build output dir {target_gen_dir}."
" Please check the board name and build Chrome once.")
with open(os.path.join(cca_root, 'tsconfig_base.json')) as f:
tsconfig = json.load(f)
make_mojom_symlink(board)
tsconfig['files'] = glob.glob('js/**/*.ts', recursive=True)
tsconfig['files'].append(os.path.join(common_definitions, 'pending.d.ts'))
tsconfig['compilerOptions']['rootDir'] = cca_root
tsconfig['compilerOptions']['noEmit'] = True
tsconfig['compilerOptions']['paths'] = get_tsc_paths(board)
# TODO(b:269971867): Remove this once we have type definition for ffmpeg.js
tsconfig['compilerOptions']['allowJs'] = True
tsconfig['compilerOptions']['plugins'] = [{
"name": "ts-lit-plugin",
"strict": True
}]
tsconfig['references'] = get_tsc_references(board)
with open(os.path.join(cca_root, 'tsconfig.json'), 'w') as f:
json.dump(tsconfig, f)
# Script to reload all CSS on the page by appending a different search
# parameter to the URL each time this is run. Note that Date.now() has
# milliseconds accuracy, so in practice multiple run of the cca.py deploy
# script will have different search parameter.
CSS_RELOAD_SCRIPT = """
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
const url = new URL(link.href);
url.searchParams.set('cca-deploy-refresh', Date.now().toString());
link.href = url.toString();
}
console.log('All CSS reloaded');
"""
def can_only_reload_css(changed_files):
for file in changed_files:
# Ignore deployed_version.js since this always change every deploy, and
# doesn't affect anything other than the startup console log and toast.
if file.endswith('/deployed_version.js'):
continue
# Ignore folders.
if file.endswith('/'):
continue
# .css change is okay.
if file.endswith('.css'):
continue
return False
return True
def reload_cca(device, changed_files):
try:
reload_script = "document.location.reload()"
if can_only_reload_css(changed_files):
reload_script = CSS_RELOAD_SCRIPT
run([
'ssh',
device,
'--',
'cca',
'open',
'&&',
'cca',
'eval',
shlex.quote(reload_script),
">",
"/dev/null",
])
except subprocess.CalledProcessError as e:
print('Failed to reload CCA on DUT, '
'please make sure that the DUT is logged in '
'and `cca setup` has been run on DUT.')
# Use a fixed temporary output folder for deploy, so incremental compilation
# works and deploy is faster.
DEPLOY_OUTPUT_TEMP_DIR = '/tmp/cca-deploy-out'
def rsync_to_device(device, src, target, *, extra_arguments=[]):
"""Returns list of files that are changed."""
cmd = [
'rsync',
'--recursive',
'--inplace',
'--delete',
'--mkpath',
'--times',
# rsync by default use source file permission masked by target file
# system umask while transferring new files, and since workstation
# defaults to have file not readable by others, this makes deployed
# file not readable by Chrome.
# Set --chmod=a+rX to rsync to fix this ('a' so it won't be affected by
# local umask, +r for read and +X for executable bit on folder), and
# set --perms so existing files that might have the wrong permission
# will have their permission fixed.
'--perms',
'--chmod=a+rX',
# Sets rsync output format to %n which prints file path that are
# changed. (By default rsync only copies file that have different size
# or modified time.)
'--out-format=%n',
*extra_arguments,
src,
f'{device}:{target}',
]
output = check_output(cmd)
return [os.path.join(target, file) for file in output.splitlines()]
def deploy(args):
root_dir = get_chromium_root()
cca_root = os.getcwd()
os.makedirs(DEPLOY_OUTPUT_TEMP_DIR, exist_ok=True)
js_out_dir = os.path.join(DEPLOY_OUTPUT_TEMP_DIR, 'js')
generate_tsconfig(args.board)
run_node([
'typescript/bin/tsc',
'--outDir',
DEPLOY_OUTPUT_TEMP_DIR,
'--noEmit',
'false',
# Makes compilation faster
'--incremental',
# For better debugging experience on DUT.
'--inlineSourceMap',
'--inlineSources',
# Makes devtools show TypeScript source with better path
'--sourceRoot',
'/js/',
# For easier developing / test cycle.
'--noUnusedLocals',
'false',
'--noUnusedParameters',
'false',
])
build_preload_images_js(js_out_dir)
# Note that although we always rerun tsc, when the JS inputs are not
# changed, tsc also doesn't change the output file's mtime, so rsync will
# correctly skip those unchanged files.
changed_files = rsync_to_device(
args.device,
f'{js_out_dir}/',
f'{CCA_OVERRIDE_PATH}/js/',
extra_arguments=['--exclude=tsconfig.tsbuildinfo'])
for dir in ['css', 'images', 'views', 'sounds']:
changed_files += rsync_to_device(args.device,
f'{os.path.join(cca_root, dir)}/',
f'{CCA_OVERRIDE_PATH}/{dir}/')
current_time = time.strftime('%F %T%z')
run([
'ssh',
args.device,
'--',
'printf',
'%s',
shlex.quote(
f'export const DEPLOYED_VERSION = "cca.py deploy {current_time}";'
),
'>',
f'{CCA_OVERRIDE_PATH}/js/deployed_version.js',
])
ensure_local_override_enabled(args.device, args.force)
if args.reload:
reload_cca(args.device, changed_files)
def test(args):
assert 'CCAUI' not in args.device, (
'The first argument should be <device> instead of a test name pattern.'
)
cmd = ['cros_run_test', '--device', args.device, '--tast'] + args.pattern
run(cmd)
def lint(args):
cmd = [
'eslint/bin/eslint.js',
'js',
'eslint_plugin',
'.eslintrc.js',
'--resolve-plugins-relative-to',
os.path.join(get_chromium_root(), 'third_party/node'),
]
if args.fix:
cmd.append('--fix')
if args.eslintrc:
cmd.extend(['--config', args.eslintrc])
try:
run_node(cmd)
except subprocess.CalledProcessError as e:
print('ESLint check failed, return code =', e.returncode)
# TODO(pihsun): Add lit-analyzer to the check. It's not included in the
# chrome source tree and can be manually installed with `npm install -g
# lit-analyzer ts-lit-plugin`. Maybe this can be added as an optional check
# for now?
def tsc(args):
generate_tsconfig(args.board)
try:
run_node(['typescript/bin/tsc'])
except subprocess.CalledProcessError as e:
print('TypeScript check failed, return code =', e.returncode)
RESOURCES_H_PATH = '../resources.h'
I18N_STRING_TS_PATH = './js/i18n_string.ts'
CAMERA_STRINGS_GRD_PATH = './strings/camera_strings.grd'
def parse_resources_h():
with open(RESOURCES_H_PATH, 'r') as f:
content = f.read()
return set(re.findall(r'\{"(\w+)",\s*(\w+)\}', content))
def parse_i18n_string_ts():
with open(I18N_STRING_TS_PATH, 'r') as f:
content = f.read()
return dict(re.findall(r"(\w+) =\s*'(\w+)'", content))
# Same as tools/check_grd_for_unused_strings.py
class GrdIDExtractor(xml.sax.handler.ContentHandler):
"""Extracts the IDs from messages in GRIT files"""
def __init__(self):
self.id_set_ = set()
def startElement(self, name, attrs):
if name == 'message':
self.id_set_.add(attrs['name'])
def allIDs(self):
"""Return all the IDs found"""
return self.id_set_.copy()
def parse_camera_strings_grd():
handler = GrdIDExtractor()
xml.sax.parse(CAMERA_STRINGS_GRD_PATH, handler)
return handler.allIDs()
def check_strings(args):
returncode = 0
def check_name_id_consistent(strings, filename):
nonlocal returncode
bad = [(name, id) for (name, id) in strings
if id != f'IDS_{name.upper()}']
if bad:
print(f'{filename} includes string id with inconsistent name:')
for (name, id) in bad:
print(f' {name}: Expect IDS_{name.upper()}, got {id}')
returncode = 1
def check_all_ids_exist(all_ids, ids, filename):
nonlocal returncode
missing = all_ids.difference(ids)
if missing:
print(f'{filename} is missing the following string id:')
print(f' {", ".join(sorted(missing))}')
returncode = 1
def check_all_name_lower_case(names, filename):
nonlocal returncode
hasUpper = [name for name in names if not name.islower()]
if hasUpper:
print(f'{filename} includes string name with upper case:')
for name in hasUpper:
print(f' Incorrect name: {name}')
returncode = 1
def check_unused(i18n_string_ts_dict: dict):
nonlocal returncode
cca_root = os.getcwd()
name_set_from_html_files = set()
id_set_from_ts_files = set()
with open(os.path.join(cca_root, 'views/main.html')) as f:
# Find all values of i18n-xxx attributes such as `i18n-text="name"`.
name_set_from_html_files.update(
re.findall(r"i18n-[\w-]+=\"(\w+)\"", f.read()))
for dirpath, _dirnames, filenames in os.walk(
os.path.join(cca_root, 'js')):
for filename in filenames:
if not filename.endswith('.ts'):
continue
with open(os.path.join(dirpath, filename)) as f:
id_set_from_ts_files.update(
re.findall(r"I18nString\.(\w+)", f.read()))
unused_ids = [
id for (id, name) in i18n_string_ts_dict.items()
if id not in id_set_from_ts_files
and name not in name_set_from_html_files
]
unused_ids = []
for (id, name) in i18n_string_ts_dict.items():
if id in id_set_from_ts_files or name in name_set_from_html_files:
continue
unused_ids.append(id)
if len(unused_ids) > 0:
print('The following strings are defined in i18n_string.ts but '
'unused. Please remove them:')
for id in unused_ids:
print(f' {id}')
returncode = 1
resources_h_strings = parse_resources_h()
check_name_id_consistent(resources_h_strings, RESOURCES_H_PATH)
resources_h_ids = set([id for (name, id) in resources_h_strings])
i18n_string_ts_dict = parse_i18n_string_ts()
check_unused(i18n_string_ts_dict)
i18n_string_ts_name_id_set = set([
(name, f'IDS_{id}') for (id, name) in i18n_string_ts_dict.items()
])
check_name_id_consistent(i18n_string_ts_name_id_set, I18N_STRING_TS_PATH)
i18n_string_ts_ids = set([id for (name, id) in i18n_string_ts_name_id_set])
resources_h_names = set([name for (name, id) in resources_h_strings])
check_all_name_lower_case(resources_h_names, RESOURCES_H_PATH)
i18n_string_ts_names = set(
[name for (name, id) in i18n_string_ts_name_id_set])
check_all_name_lower_case(i18n_string_ts_names, I18N_STRING_TS_PATH)
camera_strings_grd_ids = parse_camera_strings_grd()
all_ids = resources_h_ids.union(i18n_string_ts_ids, camera_strings_grd_ids)
check_all_ids_exist(all_ids, resources_h_ids, RESOURCES_H_PATH)
check_all_ids_exist(all_ids, i18n_string_ts_ids, I18N_STRING_TS_PATH)
check_all_ids_exist(all_ids, camera_strings_grd_ids,
CAMERA_STRINGS_GRD_PATH)
return returncode
def parse_args(args):
parser = argparse.ArgumentParser(description='CCA developer tools.')
parser.add_argument('--debug', action='store_true')
subparsers = parser.add_subparsers()
deploy_parser = subparsers.add_parser('deploy',
help='deploy to device',
description='''Deploy CCA to device.
This script only works if there's no .cc / .grd changes.
And please build Chrome at least once before running the command.'''
)
deploy_parser.add_argument('board')
deploy_parser.add_argument('device')
deploy_parser.add_argument('--force',
help="Don't prompt for restarting Chrome.",
action='store_true')
deploy_parser.add_argument(
'--reload',
help='Try reloading CCA window after deploy. '
'Please run `cca setup` on DUT once before using this argument.',
action='store_true')
deploy_parser.set_defaults(func=deploy)
test_parser = subparsers.add_parser('test',
help='run tests',
description='Run CCA tests on device.')
test_parser.add_argument('device')
test_parser.add_argument('pattern',
nargs='*',
default=['camera.CCAUI*'],
help='test patterns. (default: camera.CCAUI*)')
test_parser.set_defaults(func=test)
lint_parser = subparsers.add_parser(
'lint',
help='check code with eslint',
description='Check coding styles with eslint.')
lint_parser.add_argument('--fix', action='store_true')
lint_parser.add_argument('--eslintrc', help='use alternative eslintrc')
lint_parser.set_defaults(func=lint)
tsc_parser = subparsers.add_parser('tsc',
help='check code with tsc',
description='''Check types with tsc.
Please build Chrome at least once before running the command.''')
tsc_parser.set_defaults(func=tsc)
tsc_parser.add_argument('board')
# TODO(pihsun): Add argument to automatically generate / fix the files to a
# consistent state.
check_strings_parser = subparsers.add_parser(
'check-strings',
help='check string resources',
description='''Ensure files related to string resources are having the
same strings. This includes resources.h,
resources/strings/camera_strings.grd and
resources/js/i18n_string.ts.''')
check_strings_parser.set_defaults(func=check_strings)
parser.set_defaults(func=lambda _args: parser.print_help())
return parser.parse_args(args)
def main(args):
cca_root = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
assert os.path.basename(cca_root) == 'resources'
os.chdir(cca_root)
args = parse_args(args)
log_level = logging.DEBUG if args.debug else logging.INFO
log_format = '%(asctime)s - %(levelname)s - %(funcName)s: %(message)s'
logging.basicConfig(level=log_level, format=log_format)
logging.debug(f'args = {args}')
return args.func(args)
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))