snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
"use strict";
const {$} = require("./zjquery.cjs");
let users_matching_view = [];
exports.buddy_list_add_user_matching_view = (user_id, $stub) => {
if ($stub.attr) {
$stub.attr("data-user-id", user_id);
}
users_matching_view.push(user_id);
const sel = `li.user_sidebar_entry[data-user-id='${CSS.escape(user_id)}']`;
$("#buddy_list_wrapper").set_find_results(sel, $stub);
};
let other_users = [];
exports.buddy_list_add_other_user = (user_id, $stub) => {
if ($stub.attr) {
$stub.attr("data-user-id", user_id);
}
other_users.push(user_id);
const sel = `li.user_sidebar_entry[data-user-id='${CSS.escape(user_id)}']`;
$("#buddy_list_wrapper").set_find_results(sel, $stub);
};
exports.override_user_matches_narrow_using_loaded_data = (user_id) =>
users_matching_view.includes(user_id);
exports.clear_buddy_list = (buddy_list) => {
buddy_list.populate({
all_user_ids: [],
});
users_matching_view = [];
other_users = [];
};
exports.stub_buddy_list_elements = () => {
// Set to an empty list since we're not testing CSS.
$.reset_selector("#buddy-list-users-matching-view .empty-list-message");
$.set_results("#buddy-list-users-matching-view .empty-list-message", []);
$.reset_selector("#buddy-list-other-users .empty-list-message");
$.set_results("#buddy-list-other-users .empty-list-message", []);
$.reset_selector("#buddy-list-other-users-container .view-all-users-link");
$.set_results("#buddy-list-other-users-container .view-all-users-link", []);
// Simulate no avatar images for clear_avatar_preload_backgrounds.
$.reset_selector("#user-list .avatar-preload-background img");
$.set_results("#user-list .avatar-preload-background img", []);
};

View File

@@ -0,0 +1,43 @@
"use strict";
const compose_banner = require("../../src/compose_banner.ts");
const {noop} = require("./test.cjs");
const {$} = require("./zjquery.cjs");
exports.mock_banners = () => {
// zjquery doesn't support `remove`, which is used when clearing the compose box.
// TODO: improve how we test this so that we don't have to mock things like this.
for (const classname of Object.values(compose_banner.CLASSNAMES)) {
$(
`#compose_banners .${classname
.split(" ")
.map((classname) => CSS.escape(classname))
.join(".")}`,
)[0].remove = noop;
}
$("#compose_banners .warning")[0].remove = noop;
$("#compose_banners .error")[0].remove = noop;
$("#compose_banners .error:not(.upload_banner)")[0].remove = noop;
$("#compose_banners .upload_banner")[0].remove = noop;
$("#compose_banners .upload_banner.error")[0].remove = noop;
const $stub = $.set_results("stub_to_remove", []);
const $cb = $("#compose_banners");
$cb.set_closest_results(".edit_form_banners", $.create("edit-form-banners-stub"));
$cb.set_find_results(".upload_banner.error", $stub);
$cb.set_find_results(".no_post_permissions", $stub);
$cb.set_find_results(".message_too_long", $stub);
$cb.set_find_results(".wildcards_not_allowed", $stub);
$cb.set_find_results(".wildcard_warning", $stub);
$cb.set_find_results(".topic_missing", $stub);
$cb.set_find_results(".missing_stream", $stub);
$cb.set_find_results(".deactivated_user", $stub);
$cb.set_find_results(".missing_private_message_recipient", $stub);
$cb.set_find_results(".subscription_error", $stub);
$cb.set_find_results(".generic_compose_error", $stub);
$cb.set_find_results(".topic_resolved", $stub);
$cb.set_find_results(".search_view", $stub);
$cb.set_find_results(".unscheduled_message", $stub);
};

View File

@@ -0,0 +1,217 @@
"use strict";
const assert = require("node:assert/strict");
const {$} = require("./zjquery.cjs");
class FakeComposeBox {
constructor() {
this.$send_message_form = $("#send_message_form");
this.$content_textarea = $("textarea#compose-textarea");
this.$preview_message_area = $("#compose .preview_message_area");
// Simulate DOM relationships
this.$send_message_form.set_find_results(".message-textarea", this.$content_textarea);
// These are needed by compose_ui.enter_preview_mode() and
// compose_ui.exit_preview_mode(), which use $container.find().
const $compose = $("#compose");
$compose.set_find_results("textarea.message-textarea", this.$content_textarea);
$compose.set_find_results(
".preview_mode_disabled .compose_control_button",
$("#compose .preview_mode_disabled .compose_control_button"),
);
$compose.set_find_results(".markdown_preview", $("#compose .markdown_preview"));
$compose.set_find_results(".undo_markdown_preview", $("#compose .undo_markdown_preview"));
$compose.set_find_results(".preview_message_area", this.$preview_message_area);
$compose.set_find_results(".preview_content", $("#compose .preview_content"));
this.$send_message_form.set_find_results(
".message-limit-indicator",
$(".message-limit-indicator"),
);
const $message_row_stub = $.set_results("message_row_stub", []);
this.$content_textarea.set_closest_results(".message_row", $message_row_stub);
this.reset();
}
reset() {
$(".message-limit-indicator").html("");
$(".message-limit-indicator").text("");
$.reset_selector("#compose_banners .user_not_subscribed");
$.set_results("#compose_banners .user_not_subscribed", []);
this.$content_textarea.set_height(50);
this.$content_textarea.val("default message");
this.$content_textarea.trigger("blur");
$(".compose-submit-button .loader").show();
}
show_message_preview() {
this.$preview_message_area.show();
$("#compose .undo_markdown_preview").show();
$("#compose .markdown_preview").hide();
$("#compose").addClass("preview_mode");
}
hide_message_preview() {
this.$preview_message_area.hide();
$("#compose .markdown_preview").show();
$("#compose .undo_markdown_preview").hide();
$("#compose").removeClass("preview_mode");
}
textarea_val() {
return this.$content_textarea.val();
}
preview_content_html() {
return $("#compose .preview_content").html();
}
compose_spinner_selector() {
return ".compose-submit-button .loader";
}
markdown_spinner_selector() {
return "#compose .markdown_preview_spinner";
}
set_topic_val(topic_name) {
$("input#stream_message_recipient_topic").val(topic_name);
}
set_textarea_val(val) {
this.$content_textarea.val(val);
}
blur_textarea() {
this.$content_textarea.trigger("blur");
}
show_submit_button_spinner() {
$(".compose-submit-button .loader").show();
}
is_recipient_not_subscribed_banner_visible() {
return $("#compose_banners .recipient_not_subscribed").visible();
}
is_textarea_focused() {
return this.$content_textarea.is_focused();
}
is_submit_button_spinner_visible() {
return $(this.compose_spinner_selector()).visible();
}
trigger_submit_handler_on_compose_form(event) {
$("#compose form").get_on_handler("submit")(event);
}
click_on_markdown_preview_icon(event) {
$("#compose").get_on_handler("click", ".markdown_preview")(event);
}
click_on_undo_markdown_preview_icon(event) {
$("#compose").get_on_handler("click", ".undo_markdown_preview")(event);
}
click_on_upload_attachment_icon(event) {
$("#compose").get_on_handler("click", ".compose_upload_file")(event);
}
set_click_handler_for_upload_file_input_element(f) {
$("#compose .file_input").on("click", f);
}
assert_preview_mode_is_off() {
assert.ok(!this.$preview_message_area.visible());
assert.ok(!$("#compose .undo_markdown_preview").visible());
assert.ok($("#compose .markdown_preview").visible());
assert.ok(!$("#compose").hasClass("preview_mode"));
}
assert_preview_mode_is_on() {
assert.ok(this.$preview_message_area.visible());
assert.ok(!$("#compose .markdown_preview").visible());
assert.ok($("#compose .undo_markdown_preview").visible());
assert.ok($("#compose").hasClass("preview_mode"));
}
assert_message_size_is_over_the_limit(desired_html) {
// Indicator should show red colored text
assert.equal($(".message-limit-indicator").html(), desired_html);
assert.ok(this.$content_textarea.hasClass("textarea-over-limit"));
assert.ok($(".message-limit-indicator").hasClass("textarea-over-limit"));
assert.ok(!$("#compose-send-button").hasClass("disabled-message-send-controls"));
}
assert_message_size_is_under_the_limit(desired_html) {
// Work around the quirk that our validation code
// arbitrarily switches between html() and text(),
// and zjquery doesn't unify text and html.
if (desired_html) {
assert.equal($(".message-limit-indicator").html(), desired_html);
} else {
assert.equal($(".message-limit-indicator").text(), "");
}
assert.ok(!this.$content_textarea.hasClass("textarea-over-limit"));
assert.ok(!$(".message-limit-indicator").hasClass("textarea-over-limit"));
assert.ok(!$("#compose-send-button").hasClass("disabled-message-send-controls"));
}
}
function quote_message_template(opts) {
const {channel_object, selected_message, fence, content} = opts;
const {stream_id, name} = channel_object;
const channel_name = name;
const {sender_full_name, sender_id, id, topic} = selected_message;
const near_url = `http://zulip.zulipdev.com/#narrow/channel/${stream_id}-${channel_name}/topic/${topic}/near/${id}`;
return `translated: @_**${sender_full_name}|${sender_id}** [said](${near_url}):
${fence}quote
${content}
${fence}`;
}
function forward_channel_message_template(opts) {
const {channel_object, selected_message, fence, content} = opts;
const {stream_id, name} = channel_object;
const channel_name = name;
const {sender_full_name, sender_id, id, topic} = selected_message;
const near_url = `http://zulip.zulipdev.com/#narrow/channel/${stream_id}-${channel_name}/topic/${topic}/near/${id}`;
const topic_link = `#**${channel_name}>${topic}**`;
return `translated: @_**${sender_full_name}|${sender_id}** [said](${near_url}) in ${topic_link}:
${fence}quote
${content}
${fence}`;
}
function forward_direct_message_template(opts) {
const {direct_message, dm_recipient_string, fence, content} = opts;
const {sender_full_name, sender_id, id: message_id, display_recipient} = direct_message;
const encoding_prefix = display_recipient.length > 2 ? "group" : "dm";
const dm_recipient_ids_string = display_recipient.map((user) => user.id).join(",");
const near_url = `http://zulip.zulipdev.com/#narrow/dm/${dm_recipient_ids_string}-${encoding_prefix}/near/${message_id}`;
return `translated: @_**${sender_full_name}|${sender_id}** [said](${near_url}) to ${dm_recipient_string}:
${fence}quote
${content}
${fence}`;
}
module.exports = {
FakeComposeBox,
quote_message_template,
forward_channel_message_template,
forward_direct_message_template,
};

1395
web/tests/lib/events.cjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
"use strict";
let last_issued_group_id = 20000;
const get_group_id = () => {
last_issued_group_id += 1 + Math.floor(Math.random() * 10);
return last_issued_group_id;
};
exports.make_user_group = (opts = {}) => {
const id = opts.id ?? get_group_id();
return {
description: "Dummy user group",
id,
creator_id: 1,
date_created: null,
name: "Example user group",
members: [],
is_system_group: false,
direct_subgroup_ids: [],
can_add_members_group: 0,
can_join_group: 0,
can_leave_group: 0,
can_manage_group: 0,
can_mention_group: 0,
can_remove_members_group: 0,
deactivated: false,
...opts,
};
};

View File

@@ -0,0 +1,161 @@
"use strict";
const {server_supported_permission_settings} = require("./example_settings.cjs");
exports.make_realm = (opts = {}) => {
const default_realm = {
custom_profile_fields: [],
custom_profile_field_types: [],
giphy_api_key: "giphy-api-key",
gif_rating_policy_options: {disabled: {id: 0, name: ""}},
max_avatar_file_size_mib: 0,
max_channel_folder_description_length: 0,
max_channel_folder_name_length: 0,
max_file_upload_size_mib: 0,
max_icon_file_size_mib: 0,
max_logo_file_size_mib: 0,
max_message_length: 100,
max_reminder_note_length: 0,
max_stream_description_length: 0,
max_stream_name_length: 0,
max_topic_length: 0,
max_bulk_new_subscription_messages: 0,
password_min_guesses: 0,
password_min_length: 0,
password_max_length: 0,
realm_allow_message_editing: false,
realm_authentication_methods: {
default: {
enabled: false,
available: false,
unavailable_reason: "",
},
},
realm_available_video_chat_providers_default: {
disabled: {name: "", id: 0},
jitsi_meet: {name: "", id: 0},
zoom: undefined,
zoom_server_to_server: undefined,
big_blue_button: undefined,
},
realm_avatar_changes_disabled: false,
realm_bot_domain: "",
realm_can_access_all_users_group: 0,
realm_can_add_custom_emoji_group: 0,
realm_can_add_subscribers_group: 0,
realm_can_create_bots_group: 0,
realm_can_create_groups: 0,
realm_can_create_public_channel_group: 0,
realm_can_create_private_channel_group: 0,
realm_can_create_web_public_channel_group: 0,
realm_can_create_write_only_bots_group: 0,
realm_can_delete_any_message_group: {
direct_subgroups: [],
direct_members: [],
},
realm_can_delete_own_message_group: 0,
realm_can_invite_users_group: 0,
realm_can_manage_all_groups: 0,
realm_can_manage_billing_group: 0,
realm_can_mention_many_users_group: 0,
realm_can_move_messages_between_channels_group: 0,
realm_can_move_messages_between_topics_group: 0,
realm_can_resolve_topics_group: 0,
realm_can_set_delete_message_policy_group: 0,
realm_can_set_topics_policy_group: 0,
realm_can_summarize_topics_group: 0,
realm_create_multiuse_invite_group: 0,
realm_date_created: 0,
realm_default_code_block_language: "",
realm_default_external_accounts: {},
realm_default_language: "",
realm_description: "Dummy realm",
realm_digest_emails_enabled: false,
realm_digest_weekday: 0,
realm_direct_message_initiator_group: 0,
realm_direct_message_permission_group: 0,
realm_disallow_disposable_email_addresses: false,
realm_domains: [],
realm_email_auth_enabled: false,
realm_email_changes_disabled: false,
realm_emails_restricted_to_domains: false,
realm_embedded_bots: [],
realm_empty_topic_display_name: "",
realm_enable_guest_user_dm_warning: false,
realm_enable_guest_user_indicator: false,
realm_enable_read_receipts: false,
realm_enable_spectator_access: false,
realm_gif_rating_policy: 0,
realm_icon_source: "",
realm_icon_url: "",
realm_incoming_webhook_bots: [],
realm_media_preview_size: 100,
realm_inline_image_preview: false,
realm_inline_url_embed_preview: false,
realm_invite_required: false,
realm_jitsi_server_url: null,
realm_linkifiers: [],
realm_logo_source: "",
realm_logo_url: "",
realm_message_content_allowed_in_email_notifications: false,
realm_message_content_edit_limit_seconds: null,
realm_message_content_delete_limit_seconds: null,
realm_message_edit_history_visibility_policy: "all",
realm_message_retention_days: 0,
realm_move_messages_between_streams_limit_seconds: null,
realm_move_messages_within_stream_limit_seconds: null,
realm_name_changes_disabled: false,
realm_name: "",
realm_new_stream_announcements_stream_id: 0,
realm_night_logo_source: "",
realm_night_logo_url: "",
realm_org_type: 0,
realm_password_auth_enabled: false,
realm_plan_type: 0,
realm_playgrounds: [],
realm_presence_disabled: false,
realm_push_notifications_enabled: false,
realm_push_notifications_enabled_end_timestamp: null,
realm_require_e2ee_push_notifications: false,
realm_require_unique_names: false,
realm_send_welcome_emails: false,
realm_signup_announcements_stream_id: 0,
realm_topics_policy: "allow_empty_topic",
realm_upload_quota_mib: null,
realm_url: "",
realm_video_chat_provider: 0,
realm_waiting_period_threshold: 0,
realm_want_advertise_in_communities_directory: false,
realm_welcome_message_custom_text: "",
realm_zulip_update_announcements_stream_id: 0,
server_avatar_changes_disabled: false,
server_can_summarize_topics: false,
server_emoji_data_url: "",
server_inline_image_preview: false,
server_inline_url_embed_preview: false,
server_max_deactivated_realm_deletion_days: null,
server_min_deactivated_realm_deletion_days: null,
server_jitsi_server_url: null,
server_name_changes_disabled: false,
server_needs_upgrade: false,
server_presence_offline_threshold_seconds: 0,
server_presence_ping_interval_seconds: 0,
server_supported_permission_settings,
server_thumbnail_formats: [],
server_typing_started_expiry_period_milliseconds: 0,
server_typing_started_wait_period_milliseconds: 0,
server_typing_stopped_wait_period_milliseconds: 0,
server_web_public_streams_enabled: false,
settings_send_digest_emails: false,
stop_words: [],
upgrade_text_for_wide_organization_logo: "",
zulip_feature_level: 0,
zulip_merge_base: "",
zulip_plan_is_not_limited: false,
zulip_version: "11",
};
return {
...default_realm,
...opts,
};
};

View File

@@ -0,0 +1,384 @@
"use strict";
exports.server_supported_permission_settings = {
stream: {
can_add_subscribers_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:nobody",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_administer_channel_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "channel_creator",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_create_topic_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_delete_any_message_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:nobody",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_delete_own_message_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:nobody",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_move_messages_out_of_channel_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:nobody",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_move_messages_within_channel_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:nobody",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_remove_subscribers_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: false,
allow_everyone_group: true,
default_group_name: "role:administrators",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_resolve_topics_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:nobody",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_send_message_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_subscribe_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:nobody",
default_for_system_groups: null,
allowed_system_groups: [],
},
},
realm: {
can_access_all_users_group: {
require_system_group: true,
allow_internet_group: false,
allow_nobody_group: false,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: ["role:everyone", "role:members"],
},
can_add_custom_emoji_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_add_subscribers_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_create_bots_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_create_groups: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_create_private_channel_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_create_public_channel_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_create_web_public_channel_group: {
require_system_group: true,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:owners",
default_for_system_groups: null,
allowed_system_groups: [
"role:moderators",
"role:administrators",
"role:owners",
"role:nobody",
],
},
can_create_write_only_bots_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_delete_any_message_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:administrators",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_delete_own_message_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_invite_users_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_manage_all_groups: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: false,
allow_everyone_group: false,
default_group_name: "role:owners",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_manage_billing_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: false,
allow_everyone_group: false,
default_group_name: "role:administrators",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_mention_many_users_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:administrators",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_move_messages_between_channels_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_move_messages_between_topics_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_resolve_topics_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_set_delete_message_policy_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:moderators",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_set_topics_policy_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:members",
default_for_system_groups: null,
allowed_system_groups: [],
},
can_summarize_topics_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
create_multiuse_invite_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:administrators",
default_for_system_groups: null,
allowed_system_groups: [],
},
direct_message_initiator_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
direct_message_permission_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: null,
allowed_system_groups: [],
},
},
group: {
can_add_members_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "group_creator",
default_for_system_groups: "role:nobody",
allowed_system_groups: [],
},
can_join_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:nobody",
default_for_system_groups: "role:nobody",
allowed_system_groups: [],
},
can_leave_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: "role:nobody",
allowed_system_groups: [],
},
can_manage_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "group_creator",
default_for_system_groups: "role:nobody",
allowed_system_groups: [],
},
can_mention_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: true,
default_group_name: "role:everyone",
default_for_system_groups: "role:nobody",
allowed_system_groups: [],
},
can_remove_members_group: {
require_system_group: false,
allow_internet_group: false,
allow_nobody_group: true,
allow_everyone_group: false,
default_group_name: "role:nobody",
default_for_system_groups: "role:nobody",
allowed_system_groups: [],
},
},
};

View File

@@ -0,0 +1,57 @@
"use strict";
let last_issued_stream_id = 20000;
const get_stream_id = () => {
last_issued_stream_id += 1 + Math.floor(Math.random() * 10);
return last_issued_stream_id;
};
exports.make_stream = (opts = {}) => {
// Since other fields are computed from these, we need to
// pull these out of opts early.
const stream_id = opts.stream_id ?? get_stream_id();
const name = opts.name ?? `stream-${stream_id}`;
const default_channel = {
audible_notifications: false,
/* BUG: This should always be a group ID. But it's annoying to
* fix without assuming groups exist in the data set. */
can_remove_subscribers_group: 0,
can_administer_channel_group: 2,
can_create_topic_group: 2,
color: "abcd12",
/* This is rarely going to be the case, but a valid possibility. */
creator_id: null,
date_created: Date.now(),
description: `Description of ${name}`,
desktop_notifications: false,
email_address: "channel-email-address@example.com",
email_notifications: false,
/* This will rarely be the case, but is a valid possibility*/
first_message_id: null,
history_public_to_subscribers: true,
invite_only: false,
is_announcement_only: false,
is_muted: false,
is_web_public: false,
message_retention_days: null,
name,
newly_subscribed: false,
pin_to_top: false,
previously_subscribed: false,
push_notifications: false,
rendered_description: `<p>Description of ${name}</p>`,
stream_id,
/* STREAM_POST_POLICY_EVERYONE */
stream_post_policy: 1,
stream_weekly_traffic: 0,
/* Most tests want to work with a channel the current user is subscribed to. */
subscribed: true,
topics_policy: "inherit",
wildcard_mentions_notify: false,
folder_id: null,
};
return {...default_channel, ...opts};
};

View File

@@ -0,0 +1,73 @@
"use strict";
let last_issued_user_id = 1000;
const get_user_id = () => {
last_issued_user_id += 1 + Math.floor(Math.random() * 100);
return last_issued_user_id;
};
const Role = Object.freeze({
OWNER: 100,
ADMINISTRATOR: 200,
MODERATOR: 300,
MEMBER: 400,
GUEST: 600,
});
const Bot = Object.freeze({
GENERIC: 1,
INCOMING_WEBHOOK: 2,
OUTGOING_WEBHOOK: 3,
EMBEDDED: 4,
});
const bot_or_user_props = (opts = {}) => {
// Since other fields need `user_id`, we extract it early.
const user_id = opts.user_id ?? get_user_id();
const role = opts.role ?? Role.MEMBER;
const common_props = {
user_id,
delivery_email: opts.delivery_email ?? null,
email: `user-${user_id}@example.org`,
full_name: `user-${user_id}-ex_name`,
date_joined: Date.now(),
// Derived from role, matching people._add_user() production logic.
is_owner: role === Role.OWNER,
is_admin: role === Role.OWNER || role === Role.ADMINISTRATOR,
is_guest: role === Role.GUEST,
is_moderator: role === Role.OWNER || role === Role.ADMINISTRATOR || role === Role.MODERATOR,
timezone: "UTC",
avatar_version: 0,
role,
};
return {...common_props, ...opts};
};
const make_user = (opts = {}) => ({
...bot_or_user_props(opts),
is_bot: opts.is_bot ?? false,
// By default an empty dictionary.
profile_data: opts.profile_data ?? {},
});
const make_bot = (opts = {}) => ({
...bot_or_user_props(opts),
is_bot: true,
// By default a generic bot.
bot_type: opts.bot_type ?? Bot.GENERIC,
bot_owner_id: opts.bot_owner_id ?? null,
});
const make_cross_realm_bot = (opts = {}) => ({
...make_bot(opts),
is_system_bot: true,
});
exports.make_bot = make_bot;
exports.make_user = make_user;
exports.make_cross_realm_bot = make_cross_realm_bot;
exports.Role = Role;
exports.Bot = Bot;

View File

@@ -0,0 +1,51 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const Handlebars = require("handlebars");
const {SourceMapConsumer, SourceNode} = require("source-map");
const hb = Handlebars.create();
class ZJavaScriptCompiler extends hb.JavaScriptCompiler {
nameLookup(parent, name, type) {
// Auto-register partials with relative paths, like handlebars-loader.
if (type === "partial" && name !== "@partial-block") {
const filename = path.resolve(path.dirname(this.options.srcName), name + ".hbs");
return ["require(", JSON.stringify(filename), ")"];
}
return super.nameLookup(parent, name, type);
}
}
ZJavaScriptCompiler.prototype.compiler = ZJavaScriptCompiler;
hb.JavaScriptCompiler = ZJavaScriptCompiler;
function compile_hbs(module, filename) {
const code = fs.readFileSync(filename, "utf8");
const pc = hb.precompile(code, {
preventIndent: true,
srcName: filename,
strict: true,
explicitPartialContext: true,
});
const node = new SourceNode();
node.add([
'const Handlebars = require("handlebars/runtime.js");\n',
"module.exports = Handlebars.template(",
SourceNode.fromStringWithSourceMap(pc.code, new SourceMapConsumer(pc.map)),
");\n",
]);
const out = node.toStringWithSourceMap();
module._compile(
out.code +
"\n//# sourceMappingURL=data:application/json;charset=utf-8;base64," +
Buffer.from(out.map.toString()).toString("base64"),
filename,
);
}
exports.hook_require = () => {
require.extensions[".hbs"] = compile_hbs;
};

14
web/tests/lib/i18n.cjs Normal file
View File

@@ -0,0 +1,14 @@
"use strict";
const {zrequire} = require("./namespace.cjs");
const i18n = zrequire("i18n");
Object.assign(exports, i18n);
exports.$t = (descriptor, values, opts) =>
i18n.$t({id: descriptor.defaultMessage, descriptor}, values, opts);
/* istanbul ignore next */
exports.$t_html = (descriptor, values, opts) =>
i18n.$t_html({id: descriptor.defaultMessage, descriptor}, values, opts);

129
web/tests/lib/index.cjs Normal file
View File

@@ -0,0 +1,129 @@
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
require("@date-fns/tz"); // To prevent @sinonjs/fake-timers from interfering with it
require("css.escape");
require("handlebars/runtime.js");
const {JSDOM} = require("jsdom");
const handlebars = require("./handlebars.cjs");
const namespace = require("./namespace.cjs");
const test = require("./test.cjs");
const blueslip = require("./zblueslip.cjs");
const {$} = require("./zjquery.cjs");
const zpage_billing_params = require("./zpage_billing_params.cjs");
const zpage_params = require("./zpage_params.cjs");
process.env.NODE_ENV = "test";
const dom = new JSDOM("", {url: "http://zulip.zulipdev.com/"});
global.DOMParser = dom.window.DOMParser;
global.HTMLAnchorElement = dom.window.HTMLAnchorElement;
global.HTMLElement = dom.window.HTMLElement;
global.HTMLImageElement = dom.window.HTMLImageElement;
global.Window = dom.window.Window;
Object.defineProperty(global, "navigator", {
value: {
userAgent: "node.js",
},
writable: true,
});
require("@babel/register").default({
extensions: [".cjs", ".cts", ".js", ".mjs", ".mts", ".ts"],
root: path.resolve(__dirname, "../.."),
});
// Find the files we need to run.
const files = process.argv.slice(2);
assert.notEqual(files.length, 0, "No tests found");
// Set up our namespace helpers.
const window = new Proxy(global, {
set(_obj, prop, value) {
namespace.set_global(prop, value);
return true;
},
});
const ls_container = new Map();
const localStorage = {
getItem(key) {
return ls_container.get(key);
},
setItem(key, val) {
ls_container.set(key, val);
},
/* istanbul ignore next */
removeItem(key) {
ls_container.delete(key);
},
clear() {
ls_container.clear();
},
};
// Set up Handlebars
handlebars.hook_require();
require("../../src/templates.ts"); // register Zulip extensions
async function run_one_module(file) {
$.clear_all_elements();
console.info("running test " + path.basename(file, ".test.cjs"));
test.set_current_file_name(file);
test.suite.length = 0;
require(file);
for (const f of test.suite) {
await f();
}
namespace.complain_about_unused_mocks();
}
test.set_verbose(files.length === 1);
// In case someone mistakenly vanishes the async task with something like `await
// new Promise(() => {})`, assume failure until we establish otherwise.
process.exitCode = 1;
(async () => {
let exit_code = 0;
for (const file of files) {
namespace.start();
namespace.set_global("window", window);
namespace.set_global("location", dom.window.location);
window.location.href = "http://zulip.zulipdev.com/#";
namespace.set_global("localStorage", localStorage);
ls_container.clear();
zpage_billing_params.reset();
zpage_params.reset();
namespace.mock_esm("../../src/blueslip", blueslip);
require("../../src/blueslip.ts");
namespace.mock_esm("../../src/base_page_params", zpage_params);
require("../../src/base_page_params.ts");
namespace.mock_esm("../../src/billing/page_params", zpage_billing_params);
require("../../src/billing/page_params.ts");
namespace.mock_esm("../../src/page_params", zpage_params);
require("../../src/page_params.ts");
// Make sure we re-register our Handlebars helpers.
require("../../src/templates.ts");
try {
await run_one_module(file);
blueslip.reset();
} catch (error) /* istanbul ignore next */ {
console.error(error);
exit_code = 1;
blueslip.reset(true);
}
namespace.finish();
}
process.exitCode = exit_code;
})();

View File

@@ -0,0 +1,203 @@
"use strict";
/**
* markdown_assert.js
*
* Used to determine whether two Markdown HTML strings are semantically
* equivalent. Differs from the naive string-comparison approach in that
* differently typed but equivalent HTML fragments, such as '<p>&quot;</p>'
* and '<p>\"</p>', and '<span attr1="a" attr2="b"></span>' and
* '<span attr2="a" attr1="b"></span>', are still considered equal.
*
* The exported method equal() serves as a drop-in replacement for
* assert.equal(). Likewise, the exported method notEqual() replaces
* assert.notEqual().
*
* There is a default _output_formatter used to create the
* AssertionError error message; this function can be overridden using
* the exported setFormatter() function below.
*
* The HTML passed to the _output_formatter is not the original HTML, but
* rather a serialized version of a DOM element generated from the original
* HTML. This makes it easier to spot relevant differences.
*/
const assert = require("node:assert/strict");
const {JSDOM} = require("jsdom");
const _ = require("lodash");
const mdiff = require("./mdiff.cjs");
// Module-level global instance of MarkdownComparer, initialized when needed
let _markdownComparerInstance = null;
class MarkdownComparer {
constructor(output_formatter) {
this._output_formatter =
output_formatter ||
/* istanbul ignore next */
function (actual, expected) {
return ["Actual and expected output do not match.", actual, "!=", expected].join(
"\n",
);
};
this._document = new JSDOM().window.document;
}
/* istanbul ignore next */
setFormatter(output_formatter) {
this._output_formatter = output_formatter || this._output_formatter;
}
_htmlToElement(html, id) {
const template = this._document.createElement("template");
const id_node = this._document.createAttribute("id");
id_node.value = id;
template.setAttributeNode(id_node);
template.innerHTML = html;
return template;
}
_haveEqualContents(node1, node2) {
if (node1.content.childNodes.length !== node2.content.childNodes.length) {
return false;
}
return _.zip(node1.content.childNodes, node2.content.childNodes).every(([child1, child2]) =>
child1.isEqualNode(child2),
);
}
_reorderAttributes(node) {
// Sorts every attribute in every element by name. Ensures consistent diff HTML output
const attributeList = [...node.attributes];
// If put in above forEach loop, causes issues (possible nodes.attribute invalidation?)
for (const attr of attributeList) {
node.removeAttribute(attr.name);
}
attributeList.sort((a, b) => {
const name_a = a.name;
const name_b = b.name;
if (name_a < name_b) {
return -1;
}
if (name_a > name_b) {
return 1;
}
/* istanbul ignore next */
return 0;
});
// Put them back in, in order
for (const attribute of attributeList) {
node.setAttribute(attribute.name, attribute.value);
}
if (node.hasChildNodes()) {
for (const childNode of node.children) {
this._reorderAttributes(childNode);
}
}
if (node.content && node.content.hasChildNodes()) {
for (const childNode of node.content.children) {
this._reorderAttributes(childNode);
}
}
return node;
}
_compare(actual_markdown, expected_markdown) {
const ID_ACTUAL = "0";
const ID_EXPECTED = "1";
const element_actual = this._htmlToElement(actual_markdown, ID_ACTUAL);
const element_expected = this._htmlToElement(expected_markdown, ID_EXPECTED);
let html = {};
const are_equivalent = this._haveEqualContents(element_actual, element_expected);
if (!are_equivalent) {
html = {
actual: this._reorderAttributes(element_actual).innerHTML,
expected: this._reorderAttributes(element_expected).innerHTML,
};
}
element_actual.remove();
element_expected.remove();
return {are_equivalent, html};
}
assertEqual(actual, expected, message) {
const comparison_results = this._compare(actual, expected);
message ||= "";
message += "\n";
/* istanbul ignore if */
if (comparison_results.are_equivalent === false) {
throw new assert.AssertionError({
message:
message +
this._output_formatter(
comparison_results.html.actual,
comparison_results.html.expected,
),
});
}
}
assertNotEqual(actual, expected, message) {
const comparison_results = this._compare(actual, expected);
message ||= "";
message += "\n";
/* istanbul ignore if */
if (comparison_results.are_equivalent) {
throw new assert.AssertionError({
message:
message +
[
"actual and expected output produce semantically identical HTML",
actual,
"==",
expected,
].join("\n"),
});
}
}
}
function returnComparer() {
if (!_markdownComparerInstance) {
_markdownComparerInstance = new MarkdownComparer(
/* istanbul ignore next */
(actual, expected) =>
[
"Actual and expected output do not match. Showing diff",
mdiff.diff_strings(actual, expected),
].join("\n"),
);
}
return _markdownComparerInstance;
}
module.exports = {
equal(expected, actual, message) {
returnComparer().assertEqual(actual, expected, message);
},
notEqual(expected, actual, message) {
returnComparer().assertNotEqual(actual, expected, message);
},
/* istanbul ignore next */
setFormatter(output_formatter) {
returnComparer().setFormatter(output_formatter);
},
};

149
web/tests/lib/mdiff.cjs Normal file
View File

@@ -0,0 +1,149 @@
"use strict";
/**
* mdiff.js
*
* Used to produce colorful and informative diffs for comparison of generated
* Markdown. Unlike the built-in diffs used in python or node.js assert libraries,
* is actually designed to be effective for long, single-line comparisons.
*
* Based on diffing library difflib, a js port of the python library.
*
* The sole exported function diff_strings(string_0, string_1) returns a pretty-printed
* Unicode string containing their diff.
*/
const difflib = require("difflib");
function apply_color(input_string, changes) {
let previous_index = 0;
let processed_string = input_string.slice(0, 2);
input_string = input_string.slice(2);
const formatter = new Map([
["delete", (string) => "\u{1B}[31m" + string + "\u{1B}[0m"],
["insert", (string) => "\u{1B}[32m" + string + "\u{1B}[0m"],
["replace", (string) => "\u{1B}[33m" + string + "\u{1B}[0m"],
]);
for (const change of changes) {
if (formatter.has(change.tag)) {
processed_string += input_string.slice(previous_index, change.beginning_index);
processed_string += formatter.get(change.tag)(
input_string.slice(change.beginning_index, change.ending_index),
);
previous_index = change.ending_index;
}
}
processed_string += input_string.slice(previous_index);
return processed_string;
}
/**
* The library difflib produces diffs that look as follows:
*
* - <p>upgrade! yes</p>
* ? ^^ -
* + <p>downgrade yes.</p>
* ? ^^^^ +
*
* The purpose of this function is to facilitate converting these diffs into
* colored versions, where the question-mark lines are removed, replaced with
* directions to add appropriate color to the lines that they annotate.
*/
function parse_questionmark_line(questionmark_line) {
let current_sequence = ""; // Either "^", "-", "+", or ""
let beginning_index = 0;
let index = 0;
const changes_list = [];
const aliases = new Map([
["^", "replace"],
["+", "insert"],
["-", "delete"],
]);
const add_change = () => {
if (current_sequence) {
changes_list.push({
tag: aliases.get(current_sequence),
beginning_index,
ending_index: index,
});
current_sequence = "";
}
};
questionmark_line = questionmark_line.slice(2).trimRight("\n");
for (const character of questionmark_line) {
if (aliases.has(character)) {
if (current_sequence !== character) {
add_change();
current_sequence = character;
beginning_index = index;
}
} else {
add_change();
}
index += 1;
}
// In case we have a "change" involving the last character on a line
// e.g. a string such as "? ^^ -- ++++"
add_change();
return changes_list;
}
function diff_strings(string_0, string_1) {
let output_lines = [];
const ndiff_output = difflib.ndiff(string_0.split("\n"), string_1.split("\n"));
for (const line of ndiff_output) {
if (line.startsWith("+")) {
output_lines.push(line);
} else if (line.startsWith("-")) {
output_lines.push(line);
} else if (line.startsWith("?")) {
const changes_list = parse_questionmark_line(line);
output_lines.push(apply_color(output_lines.pop(), changes_list));
} else {
output_lines.push(line);
}
}
output_lines = output_lines.map(
(string) => "\u{1B}[34m" + string.slice(0, 1) + "\u{1B}[0m" + string.slice(1),
);
return output_lines.join("\n");
}
module.exports = {diff_strings};
// Simple CLI for this module
// Only run this code if called as a command-line utility
if (require.main === module) {
const {parseArgs} = require("node:util");
const usage =
"Usage: mdiff <string_0> <string_1>\nWhere <string_0> and <string_1> are the strings to be diffed";
const {
values: {help},
positionals,
} = parseArgs({options: {help: {type: "boolean"}}, allowPositionals: true});
const [string_0, string_1] = positionals;
if (help) {
console.log(usage);
process.exit(0);
}
if (string_1 === undefined) {
console.error(usage);
process.exit(1);
}
const output = diff_strings(string_0, string_1);
console.log(output);
}

View File

@@ -0,0 +1,19 @@
"use strict";
const {zrequire} = require("./namespace.cjs");
const {Filter} = zrequire("filter");
const {MessageList} = zrequire("message_list");
const {MessageListData} = zrequire("message_list_data");
exports.make_message_list = (filter_terms, opts = {}) => {
const filter = new Filter(filter_terms);
const default_message_list = new MessageList({
data: new MessageListData({
filter,
}),
is_node_test: true,
});
default_message_list.data.participants.humans = new Set(opts.visible_participants);
return default_message_list;
};

View File

@@ -0,0 +1,11 @@
"use strict";
// We have to pass channel here so that the test file mocks channel.ts before
// tests start running, and so that channel.ts is only mocked once.
exports.mock_channel_get = function (channel, f) {
channel.get = async (opts) => {
// await an empty promise to ensure the "fetch" happens asynchronously
await Promise.resolve();
f(opts);
};
};

434
web/tests/lib/namespace.cjs Normal file
View File

@@ -0,0 +1,434 @@
"use strict";
const assert = require("node:assert/strict");
const Module = require("node:module");
const path = require("node:path");
const FakeTimers = require("@sinonjs/fake-timers");
const {default: callsites} = require("callsites");
const {$} = require("./zjquery.cjs");
const new_globals = new Set();
let old_globals = {};
let actual_load;
const module_mocks = new Map();
const template_mocks = new Map();
const used_module_mocks = new Set();
const used_templates = new Set();
const jquery_path = require.resolve("jquery");
let in_mid_render = false;
let jquery_function;
const template_path = path.resolve(__dirname, "../../templates");
function load(request, parent, isMain) {
const filename = Module._resolveFilename(request, parent, isMain);
if (module_mocks.has(filename)) {
used_module_mocks.add(filename);
return module_mocks.get(filename);
}
if (filename.endsWith(".hbs") && filename.startsWith(template_path + path.sep)) {
const actual_render = actual_load(request, parent, isMain);
return template_stub({filename, actual_render});
}
if (filename === jquery_path) {
return {$: jquery_function ?? $};
}
const module = actual_load(request, parent, isMain);
if ((typeof module === "object" || typeof module === "function") && "__esModule" in module) {
/* istanbul ignore next */
function error_immutable() {
throw new Error(`${filename} is an immutable ES module`);
}
return new Proxy(module, {
defineProperty: error_immutable,
deleteProperty: error_immutable,
preventExtensions: error_immutable,
set: error_immutable,
setPrototypeOf: error_immutable,
});
}
return module;
}
function template_stub({filename, actual_render}) {
return function render(...args) {
// If our template was not mocked or is being rendered as a
// partial, use the actual implementation.
if (!template_mocks.has(filename) || in_mid_render) {
return actual_render(...args);
}
used_templates.add(filename);
const {exercise_template, f} = template_mocks.get(filename);
const data = args[0];
let html;
if (exercise_template) {
// If our dev wants to exercise the actual template, then do so.
// We set the in_mid_render bool so that included (i.e. partial)
// templates get rendered.
in_mid_render = true;
html = actual_render(...args);
in_mid_render = false;
}
const mock_html = f(data, html);
assert.equal(
typeof mock_html,
"string",
`The template mock for ${filename} must return a string`,
);
return mock_html;
};
}
exports.clock = FakeTimers.install();
exports.start = () => {
assert.equal(actual_load, undefined, "namespace.start was called twice in a row.");
actual_load = Module._load;
Module._load = load;
};
// We provide `mock_cjs` for mocking a CommonJS module, and `mock_esm` for
// mocking an ES6 module.
//
// A CommonJS module:
// - loads other modules using `require()`,
// - assigns its public contents to the `exports` object or `module.exports`,
// - consists of a single JavaScript value, typically an object or function,
// - when imported by an ES6 module:
// * is shallow-copied to a collection of immutable bindings, if it's an
// object,
// * is converted to a single default binding, if not.
//
// An ES6 module:
// - loads other modules using `import`,
// - declares its public contents using `export` statements,
// - consists of a collection of live bindings that may be mutated from inside
// but not outside the module,
// - may have a default binding (that's just syntactic sugar for a binding
// named `default`),
// - when required by a CommonJS module, always appears as an object.
//
// Most of our own modules are ES6 modules.
//
// For a third party module available in both formats that might present two
// incompatible APIs (especially if the CommonJS module is a function),
// Webpack will prefer the ES6 module if its availability is indicated by the
// "module" field of package.json, while Node.js will not; we need to mock the
// format preferred by Webpack.
exports.mock_cjs = (module_path, obj, {callsite = callsites()[1]} = {}) => {
assert.notEqual(
module_path,
"jquery",
"We automatically mock jquery to zjquery. Grep for mock_jquery if you want more control.",
);
const filename = Module._resolveFilename(
module_path,
require.cache[callsite.getFileName()],
false,
);
assert.ok(!module_mocks.has(filename), `You already set up a mock for ${filename}`);
assert.ok(
!Object.hasOwn(require.cache, filename),
`It is too late to mock ${filename}; call this earlier.`,
);
module_mocks.set(filename, obj);
return obj;
};
exports.mock_jquery = ($) => {
jquery_function = $; // eslint-disable-line no-jquery/variable-pattern
return $;
};
exports._start_template_mocking = () => {
template_mocks.clear();
used_templates.clear();
};
exports._finish_template_mocking = () => {
for (const filename of template_mocks.keys()) {
assert.ok(
used_templates.has(filename),
`You called mock_template with ${filename} but we never saw it get used.`,
);
}
template_mocks.clear();
used_templates.clear();
};
exports._mock_template = (fn, exercise_template, f) => {
template_mocks.set(path.join(template_path, fn), {exercise_template, f});
};
exports.mock_esm = (module_path, obj = {}, {callsite = callsites()[1]} = {}) => {
assert.equal(typeof obj, "object", "An ES module must be mocked with an object");
return exports.mock_cjs(module_path, {...obj, __esModule: true}, {callsite});
};
/* istanbul ignore next */
exports.unmock_module = (module_path, {callsite = callsites()[1]} = {}) => {
const filename = Module._resolveFilename(
module_path,
require.cache[callsite.getFileName()],
false,
);
assert.ok(module_mocks.has(filename), `Cannot unmock ${filename}, which was not mocked`);
assert.ok(
used_module_mocks.has(filename),
`You asked to mock ${filename} but we never saw it during compilation.`,
);
module_mocks.delete(filename);
used_module_mocks.delete(filename);
};
exports.set_global = function (name, val) {
assert.notEqual(val, null, `We try to avoid using null in our codebase.`);
if (!Object.hasOwn(old_globals, name)) {
if (!Object.hasOwn(global, name)) {
new_globals.add(name);
}
old_globals[name] = global[name];
}
global[name] = val;
return val;
};
exports.zrequire = function (short_fn) {
assert.notEqual(
short_fn,
"templates",
`
There is no need to zrequire templates.ts.
The test runner automatically registers the
Handlebars extensions.
`,
);
return require(`../../src/${short_fn}`);
};
const webPath = path.resolve(__dirname, "../..") + path.sep;
const testsLibPath = __dirname + path.sep;
exports.complain_about_unused_mocks = function () {
for (const filename of module_mocks.keys()) {
/* istanbul ignore if */
if (!used_module_mocks.has(filename)) {
console.error(`You asked to mock ${filename} but we never saw it during compilation.`);
}
}
};
exports.finish = function () {
/*
Handle cleanup tasks after we've run one module.
Note that we currently do lazy compilation of modules,
so we need to wait till the module tests finish
running to do things like detecting pointless mocks
and resetting our _load hook.
*/
exports.clock.reset();
jquery_function = undefined;
assert.notEqual(actual_load, undefined, "namespace.finish was called without namespace.start.");
Module._load = actual_load;
actual_load = undefined;
module_mocks.clear();
used_module_mocks.clear();
for (const path of Object.keys(require.cache)) {
if (path.startsWith(webPath) && !path.startsWith(testsLibPath)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete require.cache[path];
}
}
Object.assign(global, old_globals);
old_globals = {};
for (const name of new_globals) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete global[name];
}
new_globals.clear();
};
exports.with_overrides = function (test_function) {
// This function calls test_function() and passes in
// a way to override the namespace temporarily.
const restore_callbacks = [];
let ok = false;
const override = function (obj, prop, value, {unused = true} = {}) {
// Given an object `obj` (which is usually a module object),
// we re-map `obj[prop]` to the `value` passed in by the caller.
// Then the outer function here (`with_overrides`) automatically
// restores the original value of `obj[prop]` as its last
// step. Generally our code calls `run_test`, which wraps
// `with_overrides`.
assert.ok(
typeof obj === "object" || typeof obj === "function",
`We cannot override a function for ${typeof obj} objects`,
);
const had_value = Object.hasOwn(obj, prop);
const old_value = obj[prop];
let new_value = value;
if (typeof value === "function") {
assert.ok(
old_value === undefined || typeof old_value === "function",
`
You are overriding a non-function with a function.
This is almost certainly an error.
`,
);
new_value = function (...args) {
unused = false;
return value.apply(this, args);
};
// Let zjquery know this function was patched with override,
// so it doesn't complain about us modifying it. (Other
// code can also use this, as needed.)
new_value._patched_with_override = true;
} else {
unused = false;
}
obj[prop] = new_value;
restore_callbacks.push(() => {
if (ok) {
assert.ok(!unused, `${prop} never got invoked!`);
}
if (had_value) {
obj[prop] = old_value;
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete obj[prop];
}
});
};
const disallow = function (obj, prop) {
override(
obj,
prop,
// istanbul ignore next
() => {
throw new Error(`unexpected call to ${prop}`);
},
{unused: false},
);
};
const override_rewire = function (obj, prop, value, {unused = true} = {}) {
assert.ok(
typeof obj === "object" || typeof obj === "function",
`We cannot override a function for ${typeof obj} objects`,
);
assert.ok(Object.hasOwn(obj, prop));
const old_value = obj[prop];
let new_value = value;
if (typeof value === "function") {
assert.ok(
typeof old_value === "function",
`
You are overriding a non-function with a function.
This is almost certainly an error.
`,
);
new_value = function (...args) {
unused = false;
return value.apply(this, args);
};
} else {
unused = false;
}
const rewire_prop = `rewire_${prop}`;
/* istanbul ignore if */
if (!Object.hasOwn(obj, rewire_prop)) {
assert.fail(`You must define ${rewire_prop} to use override_rewire on ${prop}.`);
}
obj[rewire_prop](new_value);
restore_callbacks.push(() => {
if (ok) {
assert.ok(!unused, `${prop} never got invoked!`);
}
obj[rewire_prop](old_value);
});
};
const disallow_rewire = function (obj, prop) {
override_rewire(
obj,
prop,
// istanbul ignore next
() => {
throw new Error(`unexpected call to ${prop}`);
},
{unused: false},
);
};
let ret;
let is_promise = false;
try {
ret = test_function({override, override_rewire, disallow, disallow_rewire});
is_promise = typeof ret?.then === "function";
ok = !is_promise;
} finally {
if (!is_promise) {
restore_callbacks.reverse();
for (const restore_callback of restore_callbacks) {
restore_callback();
}
}
}
if (!is_promise) {
return ret;
}
return (async () => {
try {
ret = await ret;
ok = true;
return ret;
} finally {
restore_callbacks.reverse();
for (const restore_callback of restore_callbacks) {
restore_callback();
}
}
})();
};

39
web/tests/lib/stub.cjs Normal file
View File

@@ -0,0 +1,39 @@
"use strict";
const assert = require("node:assert/strict");
// Stubs don't do any magical modifications to your namespace. They
// just provide you a function that records what arguments get passed
// to it. To use stubs as something more like "spies," use something
// like set_global() to override your namespace.
exports.make_stub = function () {
const self = {num_calls: 0};
self.f = function (...args) {
self.last_call_args = args;
self.num_calls += 1;
return true;
};
self.get_args = function (...param_names) {
const result = {};
for (const [i, name] of param_names.entries()) {
result[name] = self.last_call_args[i];
}
return result;
};
return self;
};
(function test_ourselves() {
const stub = exports.make_stub();
stub.f("blue", 42);
assert.equal(stub.num_calls, 1);
const args = stub.get_args("color", "n");
assert.equal(args.color, "blue");
assert.equal(args.n, 42);
})();

55
web/tests/lib/test.cjs Normal file
View File

@@ -0,0 +1,55 @@
"use strict";
const namespace = require("./namespace.cjs");
const zblueslip = require("./zblueslip.cjs");
const {$} = require("./zjquery.cjs");
const zpage_billing_params = require("./zpage_billing_params.cjs");
const zpage_params = require("./zpage_params.cjs");
let current_file_name;
let verbose = false;
exports.set_current_file_name = (value) => {
current_file_name = value;
};
exports.set_verbose = (value) => {
verbose = value;
};
exports.noop = () => {};
exports.suite = [];
async function execute_test(label, f) {
/* istanbul ignore if */
if (verbose) {
console.info(" test: " + label);
}
$.clear_all_elements();
zpage_billing_params.reset();
zpage_params.reset();
try {
namespace._start_template_mocking();
await namespace.with_overrides(async (helpers) => {
await f({
...helpers,
mock_template: namespace._mock_template,
});
});
namespace._finish_template_mocking();
} catch (error) /* istanbul ignore next */ {
console.info("-".repeat(50));
console.info(`test failed: ${current_file_name} > ${label}`);
console.info();
throw error;
}
// defensively reset blueslip after each test.
zblueslip.reset();
}
exports.run_test = (label, f) => {
exports.suite.push(() => execute_test(label, f));
};

123
web/tests/lib/zblueslip.cjs Normal file
View File

@@ -0,0 +1,123 @@
"use strict";
const assert = require("node:assert/strict");
function make_zblueslip() {
const lib = {};
const opts = {
// Silently swallow all debug, log and info calls.
debug: false,
log: false,
info: false,
// Check against expected error values for the following.
warn: true,
error: true,
};
const names = Object.keys(opts);
// For fatal messages, we should use assert.throws
/* istanbul ignore next */
lib.fatal = (msg) => {
throw new Error(msg);
};
// Store valid test data for options.
lib.test_data = {};
lib.test_logs = {};
for (const name of names) {
lib.test_data[name] = [];
lib.test_logs[name] = [];
}
lib.expect = (name, message, expected_count = 1) => {
assert.notEqual(opts[name], undefined, `unexpected arg for expect: ${name}`);
assert.ok(
expected_count > 0 && Number.isSafeInteger(expected_count),
"expected count should be a positive integer",
);
const obj = {message, count: 0, expected_count};
lib.test_data[name].push(obj);
};
const check_seen_messages = () => {
for (const name of names) {
const logs = lib.test_logs[name];
const data = lib.test_data[name];
for (const obj of logs) {
const message = obj.message;
const i = data.findIndex((x) => x.message === message);
if (i === -1) {
// Only throw this for message types we want to explicitly track.
// For example, we do not want to throw here for debug messages.
if (opts[name] ?? false) {
throw new Error(`Unexpected '${name}' message: ${message}`);
}
continue;
}
data[i].count += 1;
}
for (const obj of data) {
const message = obj.message;
assert.equal(
obj.count,
obj.expected_count,
`Expected ${obj.expected_count} of '${name}': ${message}`,
);
}
}
};
lib.reset = (skip_checks = false) => {
if (!skip_checks) {
check_seen_messages();
}
for (const name of names) {
lib.test_data[name] = [];
lib.test_logs[name] = [];
}
};
lib.get_test_logs = (name) => lib.test_logs[name];
// Create logging functions
for (const name of names) {
if (!(opts[name] ?? false)) {
// should just log the message.
lib[name] = function (message, more_info, cause) {
lib.test_logs[name].push({message, more_info, cause});
};
continue;
}
lib[name] = function (message, more_info, cause) {
/* istanbul ignore if */
if (typeof message !== "string") {
// We may catch exceptions in blueslip, and if
// so our stub should include that.
if (message.toString().includes("exception")) {
message = message.toString();
} else {
throw new Error("message should be string: " + message);
}
}
lib.test_logs[name].push({message, more_info, cause});
const matched_error_message = lib.test_data[name].some((x) => x.message === message);
const exact_match_fail = !matched_error_message;
if (exact_match_fail) {
const error = new Error(
`Invalid ${name} message: "${message}".`,
cause === undefined ? {} : {cause},
);
error.blueslip = true;
throw error;
}
};
}
return lib;
}
module.exports = make_zblueslip();

98
web/tests/lib/zjquery.cjs Normal file
View File

@@ -0,0 +1,98 @@
"use strict";
const assert = require("node:assert/strict");
/*
When using zjquery, the first call to $("#foo")
returns a new instance of the FakeJQuery pseudoclass,
and then subsequent calls to $("#foo") get the
same instance.
*/
const {default_element, FakeJQuery} = require("./zjquery_element.cjs");
const FakeEvent = require("./zjquery_event.cjs");
function verify_selector_for_zulip(selector) {
const is_valid =
"<#.".includes(selector[0]) ||
selector === "window-stub" ||
selector === "document-stub" ||
selector === "body" ||
selector === "html" ||
selector === ":root" ||
selector.location ||
selector.includes("#") ||
selector.includes(".") ||
(selector.includes("[") && selector.indexOf("]") >= selector.indexOf("["));
assert.ok(
is_valid,
// Check if selector has only english alphabets and space.
// Then, the user is probably trying to use a tag as a selector
// like $('div a').
/^[ A-Za-z]+$/.test(selector)
? "Selector too broad! Use id, class or attributes of target instead."
: `Invalid selector: ${selector}. Use $.create() maybe?`,
);
}
function make_zjquery() {
const results = new Map();
const zjquery = function (arg, arg2) {
assert.ok(typeof arg !== "function", "zjquery does not support $(callback)");
// We occasionally create stub objects that know
// they want to be wrapped by jQuery (so they can
// in turn return stubs). The convention is that
// they provide a to_$ attribute.
if (typeof arg === "object" && "to_$" in arg) {
assert.equal(typeof arg.to_$, "function");
return arg.to_$();
}
assert.equal(
arg2,
undefined,
"We only use one-argument variations of $(...) in Zulip code.",
);
const selector = arg;
/* istanbul ignore if */
if (typeof selector !== "string") {
console.info(arg);
throw new Error("zjquery does not know how to wrap this object yet");
}
verify_selector_for_zulip(selector);
if (!results.has(selector)) {
results.set(selector, [default_element(selector)]);
}
return new FakeJQuery(results.get(selector));
};
zjquery.create = function (selector, opts) {
assert.ok(!results.has(selector), "You already created an object with this name!!");
const elements = opts?.elements ?? [default_element(selector)];
results.set(selector, elements);
return new FakeJQuery(elements);
};
zjquery.set_results = (selector, elements) => zjquery.create(selector, {elements});
zjquery.Event = FakeEvent;
zjquery.reset_selector = (selector) => {
results.delete(selector);
};
zjquery.clear_all_elements = function () {
results.clear();
};
return zjquery;
}
exports.$ = Object.freeze(make_zjquery());

View File

@@ -0,0 +1,873 @@
"use strict";
const assert = require("node:assert/strict");
const FakeEvent = require("./zjquery_event.cjs");
function split_words(x) {
return Array.isArray(x) ? x : x.trim().split(/\s+/);
}
const ignore_missing = Symbol("ignore_missing");
const reject_missing_handler = {
has(target, property) {
if (!(Reflect.has(target, property) || target[ignore_missing]?.(property))) {
throw new TypeError(`unknown property ${property} of mock ${target.constructor.name}`);
}
return Reflect.has(target, property);
},
get(target, property, receiver) {
if (!(Reflect.has(target, property) || target[ignore_missing]?.(property))) {
throw new TypeError(`unknown property ${property} of mock ${target.constructor.name}`);
}
return Reflect.get(target, property, receiver);
},
ownKeys(target) {
throw new TypeError(`enumerating properties of mock ${target.constructor.name}`);
},
};
class RejectMissing {
constructor() {
return new Proxy(this, reject_missing_handler);
}
}
class FakeClassList extends RejectMissing {
#list = new Set();
contains(class_name) {
return this.#list.has(class_name);
}
add(...class_names) {
for (const class_name of class_names) {
this.#list.add(String(class_name));
}
}
remove(...class_names) {
for (const class_name of class_names) {
this.#list.delete(String(class_name));
}
}
toggle(class_name, state = !this.#list.has(String(class_name))) {
if (state) {
this.#list.add(String(class_name));
} else {
this.#list.delete(String(class_name));
}
}
}
class FakeStyle extends RejectMissing {
#style = new Map();
get length() {
return this.#style.size;
}
getPropertyValue(name) {
return this.#style.get(name) ?? "";
}
setProperty(name, value) {
this.#style.set(name, String(value));
}
removeProperty(name) {
const value = this.#style.get(name) ?? "";
this.#style.delete(name);
return value;
}
}
class FakeElementState {
closest_results = new Map();
computed_style = new FakeStyle();
event_handlers = new Map();
delegated_event_handlers = new Map();
is_focused = false;
jquery_data = new Map();
match_results = new Map([["*", true]]);
parents_results = new Map();
query_results = new Map();
selector = undefined;
shown = false;
}
const fake_element_state = new WeakMap();
function camel(s) {
return s.replaceAll(/-([a-z])/g, (_, c) => c.toUpperCase());
}
function decamel(s) {
return s.replaceAll(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
}
function normalize_attribute(name) {
return String(name).replaceAll(/[A-Z]/g, (c) => c.toLowerCase());
}
function attribute_to_dataset_key(name) {
assert.ok(name.startsWith("data-"));
return camel(name.slice("data-".length));
}
function is_dataset_key(key) {
return !/-[a-z]/.test(key);
}
function dataset_key_to_attribute(key) {
return `data-${decamel(key)}`;
}
class FakeDataSet {
constructor(element) {
return new Proxy(this, {
get(target, key, receiver) {
return (
(is_dataset_key(key)
? element.getAttribute(dataset_key_to_attribute(key))
: null) ?? Reflect.get(target, key, receiver)
);
},
has(_target, key) {
return is_dataset_key(key) && element.hasAttribute(dataset_key_to_attribute(key));
},
set(_target, key, value) {
assert.ok(is_dataset_key(key));
element.setAttribute(dataset_key_to_attribute(key), value);
return true;
},
deleteProperty(_target, key) {
if (is_dataset_key(key)) {
element.removeAttribute(dataset_key_to_attribute(key));
}
return true;
},
ownKeys(_target) {
return element
.getAttributeNames()
.filter((name) => name.startsWith("data-"))
.map((name) => attribute_to_dataset_key(name));
},
});
}
}
// https://api.jquery.com/css/
const auto_px =
/^(Border(Top|Right|Bottom|Left)?(Width)?|(Margin|Padding)?(Top|Right|Bottom|Left)?|(Min|Max)?(Width|Height))$/;
class FakeElement extends RejectMissing {
_tippy = undefined;
classList = new FakeClassList();
dataset = new FakeDataSet(this);
innerHTML = "never-been-set";
selectionEnd = undefined;
selectionStart = undefined;
style = new FakeStyle();
textContent = "never-been-set";
value = undefined;
#attributes = new Map();
constructor() {
super();
fake_element_state.set(this, new FakeElementState());
}
append() {}
closest(selector) {
const state = fake_element_state.get(this);
if (!state.closest_results.has(selector)) {
throw new Error(
`You need to call $(${JSON.stringify(state.selector)}).set_closest_results(${JSON.stringify(selector)}, ...)`,
);
}
return state.closest_results.get(selector);
}
hasAttribute(name) {
return this.#attributes.has(normalize_attribute(name));
}
getAttribute(name) {
return this.#attributes.get(normalize_attribute(name)) ?? null;
}
getAttributeNames() {
return this.#attributes.keys();
}
matches(selector) {
const state = fake_element_state.get(this);
if (!state.match_results.has(selector)) {
throw new Error(
`You need to call $(${JSON.stringify(state.selector)}).set_matches(${JSON.stringify(selector)}, boolean)`,
);
}
return state.match_results.get(selector);
}
querySelectorAll(selector) {
const state = fake_element_state.get(this);
const results = state.query_results.get(selector);
if (results) {
return results;
}
throw new Error(
`You need to call $(${JSON.stringify(state.selector)}).set_find_results(${JSON.stringify(selector)}, ...).`,
);
}
removeAttribute(name) {
this.#attributes.delete(normalize_attribute(name));
}
replaceChildren(...children) {
assert.equal(children.length, 0, "zjquery does not support this replaceChildren() call");
fake_element_state.get(this).query_results.clear();
this.innerHTML = "";
}
setAttribute(name, value) {
this.#attributes.set(normalize_attribute(name), String(value));
}
setSelectionRange(start, end) {
this.selectionStart = start;
this.selectionEnd = end;
}
to_$() {
return new exports.FakeJQuery([this]);
}
}
exports.default_element = function (selector) {
const element = new FakeElement();
fake_element_state.get(element).selector = selector;
if (selector[0] === "<") {
element.innerHTML = selector;
}
return element;
};
function dom_args(args) {
return args.flat().flatMap((arg) => {
assert.equal(typeof arg, "object");
return arg.__zjquery ? [...arg] : [arg];
});
}
exports.FakeJQuery = class extends RejectMissing {
[Symbol.iterator] = Array.prototype.values;
__zjquery = true;
constructor(elements) {
super();
this.length = elements.length;
for (const [i, element] of elements.entries()) {
this[i] = element;
}
}
get selector() {
assert.equal(this.length, 1);
return fake_element_state.get(this[0]).selector;
}
addClass(class_names) {
class_names = split_words(class_names);
for (const element of this) {
element.classList.add(...class_names);
}
return this;
}
after(...args) {
assert.equal(this.length, 1);
this[0].after(...dom_args(args));
return this;
}
append(...args) {
assert.equal(this.length, 1);
this[0].append(...dom_args(args));
return this;
}
attr(name, ...args) {
assert.notEqual(name, undefined);
if (args.length === 0) {
return 0 in this ? (this[0].getAttribute(name) ?? undefined) : undefined;
}
const [value] = args;
for (const element of this) {
element.setAttribute(name, value);
}
return this;
}
before(...args) {
assert.equal(this.length, 1);
this[0].before(...dom_args(args));
return this;
}
caret(...args) {
if (args.length === 0) {
return this[0]?.selectionStart;
}
assert.equal(typeof args[0], "number", "zjquery does not support this caret() call");
for (const element of this) {
element.setSelectionRange(args[0], args[0]);
}
return this;
}
children(selector = "*") {
return new exports.FakeJQuery(
[...this].flatMap((element) =>
[...element.children].filter((child) => child.matches(selector)),
),
);
}
closest(selector) {
return new exports.FakeJQuery(
[...this].flatMap((element) => element.closest(selector) ?? []),
);
}
contents() {
return new exports.FakeJQuery([...this].flatMap((element) => [...element.childNodes]));
}
css(property, ...args) {
if (args.length === 0 && typeof property === "string") {
if (!(0 in this)) {
return undefined;
}
return fake_element_state
.get(this[0])
.computed_style.getPropertyValue(decamel(property));
}
if (args.length === 0 && Array.isArray(property)) {
if (!(0 in this)) {
return undefined;
}
const state = fake_element_state.get(this[0]);
return Object.fromEntries(
property.map((key) => [key, state.computed_style.getPropertyValue(decamel(key))]),
);
}
for (const element of this) {
const properties = typeof property === "string" ? {[property]: args[0]} : property;
for (const [key, value] of Object.entries(properties)) {
element.style.setProperty(
decamel(key),
typeof value === "number" &&
auto_px.test(camel(key).replace(/^./, (c) => c.toUpperCase()))
? `${value}px`
: value,
);
}
}
return this;
}
data(key, ...args) {
if (args.length === 0) {
if (!(0 in this)) {
return undefined;
}
const state = fake_element_state.get(this[0]);
if (state.jquery_data.has(key)) {
return state.jquery_data.get(key);
}
let value = this[0].getAttribute(dataset_key_to_attribute(key));
if (value === null) {
return null;
}
if (/^true$|^false$|^null$|^{.*}$|^\[.*]$/s.test(value)) {
try {
value = JSON.parse(value);
} catch {
// use the unparsed value
}
} else if (Number(value).toString() === value) {
value = Number(value);
}
state.jquery_data.set(key, value);
return value;
}
const [value] = args;
for (const element of this) {
fake_element_state.get(element).jquery_data.set(key, value);
}
return this;
}
detach() {
for (const element of this) {
element.remove();
}
return this;
}
each(callback) {
for (const [index, element] of [...this].entries()) {
callback.call(element, index, element);
}
return this;
}
empty() {
for (const element of this) {
element.replaceChildren();
}
return this;
}
expectOne() {
// silently do nothing
return this;
}
fadeIn() {
for (const element of this) {
fake_element_state.get(element).shown = true;
}
return this;
}
fadeOut(_duration, callback) {
for (const element of this) {
fake_element_state.get(element).shown = false;
callback.call();
}
return this;
}
fadeTo() {
return this;
}
filter(arg) {
return new exports.FakeJQuery(
[...this].filter(
typeof arg === "function"
? (element, index) => arg.call(element, index, element)
: (element) => element.matches(arg),
),
);
}
find(selector) {
return new exports.FakeJQuery(
[...this].flatMap((element) => [...element.querySelectorAll(selector)]),
);
}
get(index) {
return index === undefined ? [...this] : this[index];
}
get_on_handler(event_type, child_selector) {
assert.ok(0 in this);
const state = fake_element_state.get(this[0]);
if (child_selector === undefined) {
const handler = state.event_handlers.get(event_type);
assert.ok(handler !== undefined, `no ${event_type} handler for ${state.selector}`);
return handler;
}
const handler = state.delegated_event_handlers.get(child_selector)?.get(event_type);
assert.ok(
handler !== undefined,
`no ${event_type} handler for ${state.selector} ${child_selector}`,
);
return handler;
}
hasClass(class_name) {
return [...this].some((element) => element.classList.contains(class_name));
}
height(...args) {
if (args.length === 0) {
if (!(0 in this)) {
return undefined;
}
const state = fake_element_state.get(this[0]);
const height = state.computed_style.getPropertyValue("height");
assert.notEqual(
height,
"",
`Please call $(${JSON.stringify(state.selector)}).set_height`,
);
assert.ok(height.endsWith("px"));
return Number(height.slice(0, -"px".length));
}
for (const element of this) {
element.style.setProperty(
"height",
typeof args[0] === "number" ? `${args[0]}px` : args[0],
);
}
return this;
}
hide() {
for (const element of this) {
fake_element_state.get(element).shown = false;
}
return this;
}
html(...args) {
if (args.length === 0) {
return this[0]?.innerHTML;
}
const [arg] = args;
assert.equal(typeof arg, "string");
for (const element of this) {
element.innerHTML = arg;
}
return this;
}
insertAfter(...args) {
args = dom_args(args);
assert.equal(args.length, 1);
args[0].after(...this);
return this;
}
insertBefore(...args) {
args = dom_args(args);
assert.equal(args.length, 1);
args[0].before(...this);
return this;
}
is(arg) {
switch (arg) {
case ":visible":
return [...this].some((element) => fake_element_state.get(element).shown);
case ":focus":
return this.is_focused();
/* istanbul ignore next */
default:
return [...this].some((element) => element.matches(arg));
}
}
is_focused() {
// is_focused is not a jQuery thing; this is
// for our testing
assert.ok(0 in this);
return fake_element_state.get(this[0]).is_focused;
}
last() {
return new exports.FakeJQuery([...this].slice(-1));
}
next(selector = "*") {
return new exports.FakeJQuery(
[...this]
.map((element) => element.nextElementSibling)
.filter((next) => next !== null && next.matches(selector)),
);
}
off(event_type, ...args) {
if (args.length === 0) {
for (const element of this) {
const state = fake_element_state.get(element);
state.event_handlers.delete(event_type);
}
} else {
// In the Zulip codebase we never use this form of
// .off in code that we test: $(...).off('click', child_sel);
//
// So we don't support this for now.
/* istanbul ignore next */
throw new Error("zjquery does not support this call sequence");
}
return this;
}
on(event_type, ...args) {
// parameters will either be
// (event_type, handler) or
// (event_type, sel, handler)
if (args.length === 1) {
const [handler] = args;
for (const element of this) {
const state = fake_element_state.get(element);
/* istanbul ignore if */
if (state.event_handlers.has(event_type)) {
console.info("\nEither the app or the test can be at fault here..");
console.info("(sometimes you just want to call $.clear_all_elements();)\n");
throw new Error("dup " + event_type + " handler for " + state.selector);
}
state.event_handlers.set(event_type, handler);
}
} else {
assert.equal(args.length, 2, "wrong number of arguments passed in");
const [sel, handler] = args;
assert.equal(typeof sel, "string", "String selectors expected here.");
assert.equal(typeof handler, "function", "An handler function expected here.");
for (const element of this) {
const state = fake_element_state.get(element);
if (!state.delegated_event_handlers.has(sel)) {
state.delegated_event_handlers.set(sel, new Map());
}
const child_on = state.delegated_event_handlers.get(sel);
assert.ok(
!child_on.has(event_type),
`dup ${event_type} handler for ${state.selector} ${sel}`,
);
child_on.set(event_type, handler);
}
}
return this;
}
/* istanbul ignore next */
one(event_type, handler) {
return this.on(
event_type,
/* istanbul ignore next */ function (...args) {
this.off(event_type);
return handler.call(this, ...args);
},
);
}
outerHeight() {
if (!(0 in this)) {
return undefined;
}
const state = fake_element_state.get(this[0]);
const height = state.computed_style.getPropertyValue("height");
if (height === "") {
return undefined;
}
assert.ok(height.endsWith("px"));
return Number(height.slice(0, -2));
}
parent(selector = "*") {
return new exports.FakeJQuery(
[...this]
.map((element) => element.parentNode)
.filter((parent) => parent !== null && parent.matches(selector)),
);
}
parents(selector = "*") {
return new exports.FakeJQuery(
[...this].flatMap((element) => {
const state = fake_element_state.get(element);
if (!state.parents_results.has(selector)) {
throw new Error(
`You need to call $(${JSON.stringify(state.selector)}).set_parents_result(${JSON.stringify(selector)}, ...)`,
);
}
return state.parents_results.get(selector);
}),
);
}
prepend(...args) {
assert.equal(this.length, 1);
this[0].prepend(...dom_args(args));
return this;
}
prev(selector = "*") {
return new exports.FakeJQuery(
[...this]
.map((element) => element.previousElementSibling)
.filter((prev) => prev !== null && prev.matches(selector)),
);
}
prop(name, ...args) {
if (args.length === 0) {
return this[0]?.[name];
}
const [value] = args;
for (const element of this) {
element[name] = value;
}
return this;
}
range(...args) {
if (args.length === 0) {
return 0 in this
? {
start: this[0].selectionStart,
end: this[0].selectionEnd,
length: this[0].selectionEnd - this[0].selectionStart,
text: this[0].value.slice(this[0].selectionStart, this[0].selectionEnd),
}
: undefined;
}
assert.equal(typeof args[0], "number", "zjquery does not support this range() call");
for (const element of this) {
element.setSelectionRange(args[0], args[1]);
}
return this;
}
removeAttr(name) {
for (const element of this) {
element.removeAttribute(name);
}
return this;
}
removeClass(class_names) {
class_names = split_words(class_names);
for (const element of this) {
element.classList.remove(...class_names);
}
return this;
}
remove() {
for (const element of this) {
element.remove();
fake_element_state.get(element).jquery_data.clear();
}
return this;
}
removeData(keys) {
keys = split_words(keys);
for (const element of this) {
const state = fake_element_state.get(element);
for (const key of keys) {
state.jquery_data.delete(key);
}
}
return this;
}
replaceWith(...args) {
assert.equal(this.length, 1);
this[0].replaceWith(...dom_args(args));
return this;
}
set_children(elements) {
assert.equal(this.length, 1);
this[0].children = [...elements];
}
set_closest_results(selector, elements) {
assert.equal(this.length, 1);
fake_element_state.get(this[0]).closest_results.set(selector, elements[0] ?? null);
}
set_contents(nodes) {
assert.equal(this.length, 1);
this[0].childNodes = [...nodes];
}
set_find_results(selector, elements) {
assert.equal(this.length, 1);
fake_element_state.get(this[0]).query_results.set(selector, [...elements]);
}
set_height(fake_height) {
for (const element of this) {
fake_element_state
.get(element)
.computed_style.setProperty(
"height",
typeof fake_height === "number" ? `${fake_height}px` : fake_height,
);
}
}
set_matches(selector, value) {
assert.equal(this.length, 1);
fake_element_state.get(this[0]).match_results.set(selector, value);
}
set_next($result) {
assert.equal(this.length, 1);
assert.equal($result.length, 1);
this[0].nextElementSibling = $result[0];
}
set_parent($parent_elem) {
assert.equal(this.length, 1);
assert.equal($parent_elem.length, 1);
this[0].parentNode = $parent_elem[0];
}
set_parents_result(selector, elements) {
assert.equal(this.length, 1);
fake_element_state.get(this[0]).parents_results.set(selector, [...elements]);
}
set_prev($result) {
assert.equal(this.length, 1);
assert.equal($result.length, 1);
this[0].previousElementSibling = $result[0];
}
show() {
for (const element of this) {
fake_element_state.get(element).shown = true;
}
return this;
}
text(...args) {
if (args.length === 0) {
return [...this].map((element) => element.textContent).join("");
}
const [arg] = args;
for (const [i, element] of [...this].entries()) {
element.textContent =
(typeof arg === "function"
? arg.call(element, i, element.textContent)
: arg
)?.toString() ?? "";
}
return this;
}
// Used by zjquery to support $($x) === $x
to_$() {
return new exports.FakeJQuery([...this]);
}
toggle(show) {
assert.ok([true, false].includes(show));
for (const element of this) {
fake_element_state.get(element).shown = show;
}
return this;
}
toggleClass(class_names, add) {
class_names = split_words(class_names);
for (const element of this) {
for (const class_name of class_names) {
element.classList.toggle(class_name, add);
}
}
return this;
}
trigger(event_arg, extra_args) {
for (const element of this) {
const event = new FakeEvent(
typeof event_arg === "string" ? event_arg : event_arg.type,
{
target: element,
currentTarget: element,
...event_arg,
},
);
const state = fake_element_state.get(element);
const func = state.event_handlers.get(event.type);
if (func) {
// It's possible that test code will trigger events
// that haven't been set up yet, but we are trying to
// eventually deprecate trigger in our codebase, so for
// now we just let calls to trigger silently do nothing.
// (And I think actual jQuery would do the same thing.)
func.call(
element,
event,
...(Array.isArray(extra_args) ? extra_args : [extra_args]),
);
}
if (event.type === "focus" || event.type === "focusin") {
state.is_focused = true;
} else if (event.type === "blur" || event.type === "focusout") {
state.is_focused = false;
}
}
return this;
}
unwrap(...args) {
assert.equal(args.length, 0, "zjquery does not support this unwrap() call");
for (const element of this) {
element.parentNode.replaceWith(element.childNodes);
}
return this;
}
val(...args) {
if (args.length === 0) {
return 0 in this ? (this[0].value ?? "") : undefined;
}
const [value] = args;
for (const element of this) {
element.value = value;
}
return this;
}
visible() {
return [...this].some((element) => fake_element_state.get(element).shown);
}
[ignore_missing](property) {
return [
`${Number(property) >>> 0}`, // eslint-disable-line no-bitwise
"__esModule",
"stack",
].includes(property);
}
};

View File

@@ -0,0 +1,12 @@
"use strict";
class FakeEvent {
constructor(type, props) {
this.type = type;
Object.assign(this, props);
}
preventDefault() {}
stopPropagation() {}
}
module.exports = FakeEvent;

View File

@@ -0,0 +1,12 @@
"use strict";
exports.page_params = {};
exports.reset = () => {
for (const field in exports.page_params) {
if (Object.hasOwn(exports.page_params, field)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete exports.page_params[field];
}
}
};

View File

@@ -0,0 +1,22 @@
"use strict";
exports.page_params = {};
exports.reset = () => {
for (const field in exports.page_params) {
if (Object.hasOwn(exports.page_params, field)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete exports.page_params[field];
}
}
exports.page_params.request_language = "en";
exports.page_params.translation_data = new Proxy(
{},
{
get: (_target, key) => `translated: ${key}`,
getOwnPropertyDescriptor: (_target, _key) => ({configurable: true}),
},
);
};
exports.reset();