#!/usr/bin/env python3 import argparse import glob import os import pwd import subprocess import sys from typing import Any TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(TOOLS_DIR)) ROOT_DIR = os.path.dirname(TOOLS_DIR) # check for the venv from tools.lib import sanity_check sanity_check.check_venv(__file__) # Import this after we do the sanity_check so it doesn't crash. import orjson from zulint.printer import BOLDRED, CYAN, ENDC, GREEN INDEX_JS = os.path.join(ROOT_DIR, "web/tests/lib/index.cjs") NODE_COVERAGE_PATH = os.path.join(ROOT_DIR, "var/node-coverage/coverage-final.json") # Ideally, we wouldn't need this line, but it seems to be required to # avoid problems finding node_modules when running `cd tools; ./test-js-with-node`. os.chdir(ROOT_DIR) USAGE = """ tools/test-js-with-node - to run all tests tools/test-js-with-node util.test.cjs activity.test.cjs - to run just a couple tests tools/test-js-with-node --coverage - to generate coverage report """ def make_set(files: list[str]) -> set[str]: for i in range(1, len(files)): if files[i - 1] > files[i]: raise Exception(f"Please move {files[i]} so that names are sorted.") return set(files) # We do not yet require 100% line coverage for these files: EXEMPT_FILES = make_set( [ "web/shared/src/poll_data.ts", "web/src/about_zulip.ts", "web/src/add_group_members_pill.ts", "web/src/add_stream_options_popover.ts", "web/src/add_subscribers_pill.ts", "web/src/admin.js", "web/src/alert_popup.ts", "web/src/alert_words_ui.ts", "web/src/assets.d.ts", "web/src/attachments.ts", "web/src/attachments_ui.ts", "web/src/audible_notifications.ts", "web/src/avatar.ts", "web/src/base_page_params.ts", "web/src/blueslip.ts", "web/src/blueslip_stacktrace.ts", "web/src/bootstrap_typeahead.ts", "web/src/browser_history.ts", "web/src/buddy_list.ts", "web/src/click_handlers.js", "web/src/compose.js", "web/src/compose_actions.ts", "web/src/compose_banner.ts", "web/src/compose_call_ui.ts", "web/src/compose_closed_ui.ts", "web/src/compose_fade.ts", "web/src/compose_notifications.ts", "web/src/compose_popovers.ts", "web/src/compose_recipient.ts", "web/src/compose_reply.ts", "web/src/compose_send_menu_popover.js", "web/src/compose_setup.js", "web/src/compose_state.ts", "web/src/compose_textarea.ts", "web/src/compose_tooltips.ts", "web/src/compose_ui.ts", "web/src/compose_validate.ts", "web/src/composebox_typeahead.ts", "web/src/condense.ts", "web/src/confirm_dialog.ts", "web/src/copied_tooltip.ts", "web/src/copy_and_paste.ts", "web/src/csrf.ts", "web/src/css_variables.d.ts", "web/src/css_variables.js", "web/src/custom_profile_fields_ui.ts", "web/src/debug.ts", "web/src/demo_organizations_ui.ts", "web/src/deprecated_feature_notice.ts", "web/src/desktop_integration.js", "web/src/desktop_notifications.ts", "web/src/dialog_widget.ts", "web/src/drafts.ts", "web/src/drafts_overlay_ui.js", "web/src/dropdown_widget.ts", "web/src/echo.ts", "web/src/electron_bridge.ts", "web/src/email_pill.ts", "web/src/emoji_picker.ts", "web/src/emojisets.ts", "web/src/favicon.ts", "web/src/feedback_widget.ts", "web/src/fetch_status.ts", "web/src/flatpickr.ts", "web/src/gear_menu.js", "web/src/giphy.ts", "web/src/giphy_state.ts", "web/src/global.ts", "web/src/group_setting_pill.ts", "web/src/hash_util.ts", "web/src/hashchange.js", "web/src/hbs.d.ts", "web/src/hotkey.js", "web/src/inbox_ui.ts", "web/src/inbox_util.ts", "web/src/info_overlay.ts", "web/src/information_density.ts", "web/src/input_pill.ts", "web/src/integration_url_modal.ts", "web/src/invite.ts", "web/src/invite_stream_picker_pill.ts", "web/src/left_sidebar_navigation_area.ts", "web/src/left_sidebar_navigation_area_popovers.ts", "web/src/lightbox.ts", "web/src/list_util.ts", "web/src/list_widget.ts", "web/src/loading.ts", "web/src/local_message.ts", "web/src/localstorage.ts", "web/src/message_actions_popover.js", "web/src/message_edit.ts", "web/src/message_edit_history.ts", "web/src/message_events.js", "web/src/message_events_util.ts", "web/src/message_feed_loading.ts", "web/src/message_feed_top_notices.ts", "web/src/message_fetch.ts", "web/src/message_list.ts", "web/src/message_list_data.ts", "web/src/message_list_data_cache.ts", "web/src/message_list_hover.ts", "web/src/message_list_tooltips.ts", "web/src/message_list_view.ts", "web/src/message_lists.ts", "web/src/message_live_update.ts", "web/src/message_notifications.ts", "web/src/message_scroll.js", "web/src/message_scroll_state.ts", "web/src/message_util.ts", "web/src/message_view.ts", "web/src/message_view_header.ts", "web/src/message_viewport.ts", "web/src/messages_overlay_ui.ts", "web/src/modals.ts", "web/src/muted_users_ui.ts", "web/src/narrow_history.ts", "web/src/narrow_title.ts", "web/src/navbar_alerts.ts", "web/src/navbar_help_menu.ts", "web/src/navbar_menus.js", "web/src/navigate.js", "web/src/onboarding_steps.ts", "web/src/overlay_util.ts", "web/src/overlays.ts", "web/src/padded_widget.ts", "web/src/page_params.ts", "web/src/personal_menu_popover.ts", "web/src/playground_links_popover.ts", "web/src/plotly.js.d.ts", "web/src/pm_list.ts", "web/src/pm_list_dom.ts", "web/src/poll_modal.ts", "web/src/poll_widget.ts", "web/src/popover_menus.ts", "web/src/popover_menus_data.ts", "web/src/popovers.ts", "web/src/read_receipts.ts", "web/src/realm_icon.ts", "web/src/realm_logo.ts", "web/src/realm_playground.ts", "web/src/recent_view_ui.ts", "web/src/reload.ts", "web/src/reload_setup.js", "web/src/resize.ts", "web/src/resize_handler.ts", "web/src/rows.ts", "web/src/saved_snippets_ui.ts", "web/src/scheduled_messages.ts", "web/src/scheduled_messages_feed_ui.ts", "web/src/scheduled_messages_overlay_ui.ts", "web/src/scheduled_messages_ui.ts", "web/src/scroll_bar.ts", "web/src/scroll_util.ts", "web/src/search.ts", "web/src/search_pill.ts", "web/src/search_suggestion.ts", "web/src/sent_messages.ts", "web/src/sentry.ts", "web/src/server_events.js", "web/src/settings.js", "web/src/settings_account.ts", "web/src/settings_bots.ts", "web/src/settings_components.ts", "web/src/settings_emoji.ts", "web/src/settings_exports.ts", "web/src/settings_invites.ts", "web/src/settings_linkifiers.ts", "web/src/settings_muted_users.ts", "web/src/settings_notifications.ts", "web/src/settings_org.ts", "web/src/settings_panel_menu.ts", "web/src/settings_playgrounds.ts", "web/src/settings_preferences.ts", "web/src/settings_profile_fields.ts", "web/src/settings_realm_domains.ts", "web/src/settings_realm_user_settings_defaults.ts", "web/src/settings_sections.ts", "web/src/settings_streams.ts", "web/src/settings_toggle.js", "web/src/settings_ui.ts", "web/src/settings_user_topics.ts", "web/src/settings_users.ts", "web/src/setup.ts", "web/src/sidebar_ui.ts", "web/src/spectators.ts", "web/src/spoilers.ts", "web/src/starred_messages_ui.ts", "web/src/state_data.ts", "web/src/stream_card_popover.ts", "web/src/stream_color.ts", "web/src/stream_color_events.ts", "web/src/stream_create.ts", "web/src/stream_create_subscribers.ts", "web/src/stream_edit.ts", "web/src/stream_edit_subscribers.ts", "web/src/stream_edit_toggler.ts", "web/src/stream_list.ts", "web/src/stream_muting.ts", "web/src/stream_popover.js", "web/src/stream_settings_api.ts", "web/src/stream_settings_components.ts", "web/src/stream_settings_containers.ts", "web/src/stream_settings_ui.ts", "web/src/stream_ui_updates.ts", "web/src/submessage.ts", "web/src/subscriber_api.ts", "web/src/theme.ts", "web/src/thumbnail.ts", "web/src/timerender.ts", "web/src/tippyjs.ts", "web/src/todo_widget.ts", "web/src/topic_list.ts", "web/src/topic_popover.js", "web/src/typing.ts", "web/src/typing_events.ts", "web/src/ui_init.js", "web/src/ui_report.ts", "web/src/ui_util.ts", "web/src/unread.ts", "web/src/unread_ops.ts", "web/src/unread_ui.ts", "web/src/upload.ts", "web/src/upload_widget.ts", "web/src/url-template.d.ts", "web/src/user_card_popover.js", "web/src/user_deactivation_ui.ts", "web/src/user_events.js", "web/src/user_group_components.ts", "web/src/user_group_create.ts", "web/src/user_group_create_members.ts", "web/src/user_group_create_members_data.ts", "web/src/user_group_edit.js", "web/src/user_group_edit_members.ts", "web/src/user_group_popover.ts", "web/src/user_groups.ts", "web/src/user_pill.ts", "web/src/user_profile.ts", "web/src/user_sort.ts", "web/src/user_status.ts", "web/src/user_status_ui.ts", "web/src/user_topic_popover.ts", "web/src/user_topics.ts", "web/src/user_topics_ui.ts", "web/src/views_util.ts", "web/src/zcommand.ts", "web/src/zform.js", "web/src/zulip_test.ts", "web/tests/lib/example_user.cjs", "web/tests/lib/mdiff.cjs", "web/tests/lib/real_jquery.cjs", "web/tests/lib/zjquery_element.cjs", "web/tests/lib/zpage_billing_params.cjs", # There are some important functions which are not called right now but will # be reused when we add tests for dropdown widget so it doesn't make sense to remove them. "web/tests/recent_view.test.cjs", ] ) from tools.lib.test_script import add_provision_check_override_param, assert_provisioning_status_ok parser = argparse.ArgumentParser(USAGE) parser.add_argument("--coverage", action="store_true", help="Get coverage report") add_provision_check_override_param(parser) parser.add_argument("args", nargs=argparse.REMAINDER) parser.add_argument( "--parallel", dest="parallel", action="store", type=int, # Since process startup time is a significant portion of total # runtime, so rather than doing os.cpu_count, we just do a fixed 4 # processes by default. default=4, help="Specify the number of processes to run the " "tests in. Default is the number of logical CPUs", ) options = parser.parse_args() individual_files = options.args parallel = options.parallel if options.coverage and parallel > 1: parallel = 1 print( BOLDRED + "You cannot use --coverage with parallel tests. Running in serial mode.\n" + ENDC ) assert_provisioning_status_ok(options.skip_provision_check) def get_dev_host() -> str: # See similar code in dev_settings.py. We only use # this to report where you can find coverage reports. # We duplicate the code here to avoid depending on # Django. host = os.getenv("EXTERNAL_HOST") if host is not None: return host user_id = os.getuid() user_name = pwd.getpwuid(user_id).pw_name if user_name == "zulipdev": hostname = os.uname()[1].lower() if ".zulipdev.org" not in hostname: hostname += ".zulipdev.org" return hostname + ":9991" else: # For local development environments, we use localhost by # default, via the "zulipdev.com" hostname. return "zulipdev.com:9991" def print_error(msg: str) -> None: print(BOLDRED + "ERROR:" + ENDC + " " + msg) def clean_file(orig_fn: str) -> str: fn = orig_fn if not fn.endswith(".test.cjs"): fn += ".test.cjs" if "web/tests/" not in fn: fn = os.path.join(ROOT_DIR, "web", "tests", fn) fn = os.path.abspath(fn) if not os.path.exists(fn): print(f"Cannot find {orig_fn} ({fn})") sys.exit(1) return fn def clean_files(fns: list[str]) -> list[str]: cleaned_files = [clean_file(fn) for fn in fns] return cleaned_files def run_tests_via_node_js() -> int: os.environ["TZ"] = "UTC" # The index.cjs test runner is the real "driver" here, and we launch # with either nyc or node, depending on whether we want coverage # reports. Running under nyc is slower and creates funny # tracebacks, so you generally want to get coverage reports only # after making sure tests will pass. node_tests_cmd = [ "node", "--stack-trace-limit=100", "--experimental-require-module", "--no-warnings=ExperimentalWarning", INDEX_JS, ] if individual_files: # If we passed a specific set of tests, run in serial mode. global parallel parallel = 1 files = individual_files else: files = sorted(glob.glob(os.path.join(ROOT_DIR, "web/tests/*.test.cjs"))) test_files = clean_files(files) print("Starting node tests...") # If we got this far, we can run the tests! ret = 0 if parallel > 1: sub_tests = [test_files[i::parallel] for i in range(parallel)] parallel_processes = [subprocess.Popen(node_tests_cmd + sub_test) for sub_test in sub_tests] for process in parallel_processes: status_code = process.wait() if status_code != 0: ret = status_code return ret node_tests_cmd += test_files if options.coverage: os.environ["USING_INSTRUMENTED_CODE"] = "TRUE" coverage_dir = os.path.join(ROOT_DIR, "var/node-coverage") nyc = os.path.join(ROOT_DIR, "node_modules/.bin/nyc") command = [nyc] command += [f"--extension={ext}" for ext in [".cjs", ".cts", ".hbs", ".mjs", ".mts", ".ts"]] command += ["--report-dir", coverage_dir] command += ["--temp-directory", coverage_dir] command += ["-r=lcov", "-r=json", "-r=text-summary"] command += node_tests_cmd else: # Normal testing, no coverage analysis. # Run the index.cjs test runner, which runs all the other tests. command = node_tests_cmd try: ret = subprocess.check_call(command) except OSError: print(f"Bad command: {command}") raise except subprocess.CalledProcessError: print("\n** Tests failed, PLEASE FIX! **\n") sys.exit(1) return ret def check_line_coverage( fn: str, line_coverage: dict[Any, Any], line_mapping: dict[Any, Any], log: bool = True ) -> bool: missing_lines = [] for line in line_coverage: if line_coverage[line] == 0: actual_line = line_mapping[line] missing_lines.append(str(actual_line["start"]["line"])) if missing_lines: if log: print_error(f"{fn} no longer has complete node test coverage") print(" Lines missing coverage: {}".format(", ".join(sorted(missing_lines, key=int)))) print() return False return True def read_coverage() -> Any: coverage_json = None try: with open(NODE_COVERAGE_PATH, "rb") as f: coverage_json = orjson.loads(f.read()) except OSError: print(NODE_COVERAGE_PATH + " doesn't exist. Cannot enforce fully covered files.") raise return coverage_json def enforce_proper_coverage(coverage_json: Any) -> bool: all_js_files = { *glob.glob("web/shared/src/*.js"), *glob.glob("web/shared/src/*.ts"), *glob.glob("web/src/*.js"), *glob.glob("web/src/*.ts"), *glob.glob("web/src/billing/*.js"), *glob.glob("web/tests/*.cjs"), *glob.glob("web/tests/lib/*.cjs"), } missing_files = sorted(EXEMPT_FILES - all_js_files) assert not missing_files, f"Missing files should be removed from EXEMPT_FILES: {missing_files}" enforce_fully_covered = sorted(all_js_files - EXEMPT_FILES) coverage_lost = False for relative_path in enforce_fully_covered: path = ROOT_DIR + "/" + relative_path if path not in coverage_json: coverage_lost = True print_error(f"{relative_path} has no node test coverage") continue line_coverage = coverage_json[path]["s"] line_mapping = coverage_json[path]["statementMap"] if not check_line_coverage(relative_path, line_coverage, line_mapping): coverage_lost = True if coverage_lost: print() print("It looks like your changes lost 100% test coverage in one or more files.") print("Ideally, you should add some tests to restore coverage.") print("A worse option is to update EXEMPT_FILES in `tools/test-js-with-node`.") print("To run this check locally, use `test-js-with-node --coverage`.") print() coverage_not_enforced = False for path in coverage_json: relative_path = os.path.relpath(path, ROOT_DIR) if relative_path in EXEMPT_FILES: line_coverage = coverage_json[path]["s"] line_mapping = coverage_json[path]["statementMap"] if check_line_coverage(relative_path, line_coverage, line_mapping, log=False): coverage_not_enforced = True print_error(f"{relative_path} unexpectedly has 100% line coverage.") if coverage_not_enforced: print() print("One or more fully covered files are miscategorized.") print("Remove the file(s) from EXEMPT_FILES in `tools/test-js-with-node`.") problems_encountered = coverage_lost or coverage_not_enforced return problems_encountered ret = run_tests_via_node_js() if options.coverage and ret == 0: if not individual_files: coverage_json = read_coverage() problems_encountered = enforce_proper_coverage(coverage_json) if problems_encountered: ret = 1 reports_location = f"http://{get_dev_host()}/node-coverage/index.html" print() print("View coverage reports at " + CYAN + reports_location + ENDC) print() if ret == 0: print(GREEN + "Test(s) passed. SUCCESS!" + ENDC) else: print(BOLDRED + "FAIL - Test(s) failed" + ENDC) sys.exit(ret)