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

659
web/tests/activity.test.cjs Normal file
View File

@@ -0,0 +1,659 @@
"use strict";
const assert = require("node:assert/strict");
const {
clear_buddy_list,
override_user_matches_narrow_using_loaded_data,
buddy_list_add_user_matching_view,
buddy_list_add_other_user,
stub_buddy_list_elements,
} = require("./lib/buddy_list.cjs");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {make_message_list} = require("./lib/message_list.cjs");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {$} = require("./lib/zjquery.cjs");
const _document = {
hasFocus() {
return true;
},
};
const buddy_list_presence = mock_esm("../src/buddy_list_presence");
const keydown_util = mock_esm("../src/keydown_util", {handle() {}});
const padded_widget = mock_esm("../src/padded_widget");
const pm_list = mock_esm("../src/pm_list");
const popovers = mock_esm("../src/popovers");
const settings_data = mock_esm("../src/settings_data");
const sidebar_ui = mock_esm("../src/sidebar_ui");
const scroll_util = mock_esm("../src/scroll_util");
const background_task = mock_esm("../src/background_task");
set_global("document", _document);
const muted_users = zrequire("muted_users");
const presence = zrequire("presence");
const people = zrequire("people");
const buddy_data = zrequire("buddy_data");
const {buddy_list} = zrequire("buddy_list");
const activity = zrequire("activity");
const activity_ui = zrequire("activity_ui");
const stream_data = zrequire("stream_data");
const peer_data = zrequire("peer_data");
const message_lists = zrequire("message_lists");
const util = zrequire("util");
const {set_current_user, set_realm} = zrequire("state_data");
const {initialize_user_settings} = zrequire("user_settings");
const current_user = {};
set_current_user(current_user);
const realm = make_realm();
set_realm(realm);
const user_settings = {};
initialize_user_settings({user_settings});
const me = make_user({
email: "me@zulip.com",
user_id: 999,
full_name: "Me Myself",
});
const alice = make_user({
email: "alice@zulip.com",
user_id: 1,
full_name: "Alice Smith",
});
const fred = make_user({
email: "fred@zulip.com",
user_id: 2,
full_name: "Fred Flintstone",
});
const jill = make_user({
email: "jill@zulip.com",
user_id: 3,
full_name: "Jill Hill",
});
const mark = make_user({
email: "mark@zulip.com",
user_id: 4,
full_name: "Marky Mark",
});
const norbert = make_user({
email: "norbert@zulip.com",
user_id: 5,
full_name: "Norbert Oswald",
});
const zoe = make_user({
email: "zoe@example.com",
user_id: 6,
full_name: "Zoe Yang",
});
people.add_active_user(alice, "server_events");
people.add_active_user(fred, "server_events");
people.add_active_user(jill, "server_events");
people.add_active_user(mark, "server_events");
people.add_active_user(norbert, "server_events");
people.add_active_user(zoe, "server_events");
people.add_active_user(me, "server_events");
people.initialize_current_user(me.user_id);
const $alice_stub = $.create("alice stub");
const $fred_stub = $.create("fred stub");
const rome_sub = make_stream({
name: "Rome",
subscribed: true,
stream_id: 1001,
});
function add_sub_and_set_as_current_narrow(sub) {
stream_data.add_sub_for_tests(sub);
const filter_terms = [{operator: "stream", operand: String(sub.stream_id)}];
message_lists.set_current(make_message_list(filter_terms));
}
function test(label, f) {
run_test(label, (helpers) => {
helpers.override(user_settings, "presence_enabled", true);
// Simulate a small window by having the
// fill_screen_with_content render the entire
// list in one pass. We will do more refined
// testing in the buddy_list node tests.
helpers.override(buddy_list, "fill_screen_with_content", () => {
buddy_list.render_more({
chunk_size: 100,
});
});
stub_buddy_list_elements();
presence.presence_info.set(alice.user_id, {status: "active"});
presence.presence_info.set(fred.user_id, {status: "active"});
presence.presence_info.set(jill.user_id, {status: "active"});
presence.presence_info.set(mark.user_id, {status: "idle"});
presence.presence_info.set(norbert.user_id, {status: "active"});
presence.presence_info.set(zoe.user_id, {status: "active"});
presence.presence_info.set(me.user_id, {status: "active"});
helpers.override(background_task, "run_async_function_without_await", noop);
clear_buddy_list(buddy_list);
muted_users.set_muted_users([]);
activity.clear_for_testing();
activity_ui.set_cursor_and_filter();
f(helpers);
presence.clear_internal_data();
});
}
run_test("reload_defaults", () => {
activity.clear_for_testing();
activity_ui.clear_for_testing();
blueslip.expect("warn", "get_filter_text() is called before initialization");
assert.equal(activity_ui.get_filter_text(), "");
});
test("presence_list_full_update", ({override}) => {
override(padded_widget, "update_padding", noop);
let args = [];
override(buddy_list, "items_to_html", (opts) => {
args = opts;
return "<presence-rows>";
});
$("input.user-list-filter").trigger("focus");
const user_ids = activity_ui.build_user_sidebar();
assert.deepEqual(user_ids, [
me.user_id,
alice.user_id,
fred.user_id,
jill.user_id,
norbert.user_id,
zoe.user_id,
mark.user_id,
]);
assert.equal(args.items.length, 7);
assert.equal(args.items[0].user_id, me.user_id);
});
test("direct_message_update_dom_counts", () => {
const $count = $.create("alice-unread-count");
const pm_key = alice.user_id.toString();
const $li = $.create("alice stub");
buddy_list_add_user_matching_view(pm_key, $li);
$li.set_find_results(".unread_count", $count);
$count.set_parents_result("li", $li);
const counts = new Map([[pm_key, 5]]);
$li.addClass("user_sidebar_entry");
activity_ui.update_dom_with_unread_counts({pm_count: counts});
assert.equal($count.text(), "5");
counts.set(pm_key, 0);
activity_ui.update_dom_with_unread_counts({pm_count: counts});
assert.equal($count.text(), "");
});
test("handlers", ({override, override_rewire}) => {
let filter_key_handlers;
override(keydown_util, "handle", (opts) => {
filter_key_handlers = opts.handlers;
});
override(scroll_util, "scroll_element_into_container", noop);
override(padded_widget, "update_padding", noop);
override(popovers, "hide_all", noop);
override(sidebar_ui, "hide_all", noop);
// This is kind of weak coverage; we are mostly making sure that
// keys and clicks got mapped to functions that don't crash.
const $me_li = $.create("me stub");
const $alice_li = $.create("alice stub");
const $fred_li = $.create("fred stub");
// Simulate a small window by having the
// fill_screen_with_content render the entire
// list in one pass. We will do more refined
// testing in the buddy_list node tests.
override(buddy_list, "fill_screen_with_content", () => {
buddy_list.render_more({
chunk_size: 100,
});
buddy_list_add_user_matching_view(me.user_id, $me_li);
buddy_list_add_user_matching_view(alice.user_id, $alice_li);
buddy_list_add_user_matching_view(fred.user_id, $fred_li);
});
let narrowed;
function narrow_by_user_id(user_id) {
assert.equal(user_id, alice.user_id);
narrowed = true;
}
function init() {
$.clear_all_elements();
stub_buddy_list_elements();
buddy_list.start_scroll_handler = noop;
override_rewire(util, "call_function_periodically", noop);
override_rewire(activity, "send_presence_to_server", noop);
activity_ui.initialize({narrow_by_user_id});
buddy_list.populate({
all_user_ids: [me.user_id, alice.user_id, fred.user_id],
});
}
(function test_filter_keys() {
init();
activity_ui.user_cursor.go_to(alice.user_id);
filter_key_handlers.ArrowDown();
filter_key_handlers.ArrowUp();
})();
(function test_click_filter() {
init();
const e = {
stopPropagation() {},
};
const handler = $("input.user-list-filter").get_on_handler("focus");
handler(e);
})();
(function test_enter_key() {
init();
$("input.user-list-filter").val("al");
narrowed = false;
activity_ui.user_cursor.go_to(alice.user_id);
filter_key_handlers.Enter();
assert.ok(narrowed);
// get line coverage for cleared case
activity_ui.user_cursor.clear();
filter_key_handlers.Enter();
})();
(function test_click_handler() {
init();
// We wire up the click handler in click_handlers.ts,
// so this just tests the called function.
narrowed = false;
activity_ui.narrow_for_user({$li: $alice_li});
assert.ok(narrowed);
})();
(function test_blur_filter() {
init();
const e = {};
const handler = $("input.user-list-filter").get_on_handler("blur");
handler(e);
})();
});
test("first/prev/next", ({override, override_rewire}) => {
override_rewire(
buddy_data,
"user_matches_narrow_using_loaded_data",
override_user_matches_narrow_using_loaded_data,
);
override(padded_widget, "update_padding", noop);
stub_buddy_list_elements();
// empty list
assert.equal(buddy_list.first_key(), undefined);
blueslip.reset();
blueslip.expect("error", "Couldn't find key in buddy list");
assert.equal(buddy_list.prev_key(alice.user_id), undefined);
blueslip.reset();
blueslip.expect("error", "Couldn't find key in buddy list");
assert.equal(buddy_list.next_key(alice.user_id), undefined);
blueslip.reset();
// one user matching the view
clear_buddy_list(buddy_list);
buddy_list_add_user_matching_view(alice.user_id, $alice_stub);
buddy_list.populate({
all_user_ids: [alice.user_id],
});
assert.equal(buddy_list.first_key(), alice.user_id);
assert.equal(buddy_list.prev_key(alice.user_id), undefined);
assert.equal(buddy_list.next_key(alice.user_id), undefined);
// two users matching the view
clear_buddy_list(buddy_list);
buddy_list_add_user_matching_view(alice.user_id, $alice_stub);
buddy_list_add_user_matching_view(fred.user_id, $fred_stub);
buddy_list.populate({
all_user_ids: [alice.user_id, fred.user_id],
});
assert.equal(buddy_list.first_key(), alice.user_id);
assert.equal(buddy_list.prev_key(alice.user_id), undefined);
assert.equal(buddy_list.prev_key(fred.user_id), alice.user_id);
assert.equal(buddy_list.next_key(alice.user_id), fred.user_id);
assert.equal(buddy_list.next_key(fred.user_id), undefined);
// one other user
clear_buddy_list(buddy_list);
buddy_list_add_other_user(fred.user_id, $fred_stub);
buddy_list.populate({
all_user_ids: [fred.user_id],
});
assert.equal(buddy_list.first_key(), fred.user_id);
assert.equal(buddy_list.prev_key(fred.user_id), undefined);
assert.equal(buddy_list.next_key(fred.user_id), undefined);
// two other users
clear_buddy_list(buddy_list);
buddy_list_add_other_user(alice.user_id, $alice_stub);
buddy_list_add_other_user(fred.user_id, $fred_stub);
buddy_list.populate({
all_user_ids: [alice.user_id, fred.user_id],
});
assert.equal(buddy_list.first_key(), alice.user_id);
assert.equal(buddy_list.prev_key(alice.user_id), undefined);
assert.equal(buddy_list.prev_key(fred.user_id), alice.user_id);
assert.equal(buddy_list.next_key(alice.user_id), fred.user_id);
assert.equal(buddy_list.next_key(fred.user_id), undefined);
// one user matching the view, and one other user
clear_buddy_list(buddy_list);
buddy_list_add_user_matching_view(alice.user_id, $alice_stub);
buddy_list_add_other_user(alice.user_id, $fred_stub);
buddy_list.populate({
all_user_ids: [alice.user_id, fred.user_id],
});
assert.equal(buddy_list.first_key(), alice.user_id);
assert.equal(buddy_list.prev_key(alice.user_id), undefined);
assert.equal(buddy_list.prev_key(fred.user_id), alice.user_id);
assert.equal(buddy_list.next_key(alice.user_id), fred.user_id);
assert.equal(buddy_list.next_key(fred.user_id), undefined);
});
test("insert_one_user_into_empty_list", ({override}) => {
override(buddy_list_presence, "update_indicators", noop);
override(user_settings, "user_list_style", 2);
override(padded_widget, "update_padding", noop);
let num_calls = 0;
override(buddy_list, "item_to_html", (data) => {
assert.deepEqual(data.item, {
href: "#narrow/dm/1-Alice-Smith",
name: "Alice Smith",
user_id: 1,
is_current_user: false,
num_unread: 0,
profile_picture: "/avatar/1",
user_circle_class: "user-circle-active",
status_emoji_info: undefined,
status_text: undefined,
has_status_text: false,
user_list_style: {
COMPACT: false,
WITH_STATUS: true,
WITH_AVATAR: false,
},
should_add_guest_user_indicator: false,
});
num_calls += 1;
return "<presence-row>";
});
add_sub_and_set_as_current_narrow(rome_sub);
buddy_list_add_user_matching_view(alice.user_id, $alice_stub);
peer_data.set_subscribers(rome_sub.stream_id, [alice.user_id]);
activity_ui.redraw_user(alice.user_id);
assert.equal(num_calls, 1);
clear_buddy_list(buddy_list);
buddy_list_add_other_user(alice.user_id, $alice_stub);
peer_data.set_subscribers(rome_sub.stream_id, []);
activity_ui.redraw_user(alice.user_id);
assert.equal(num_calls, 2);
});
test("insert_alice_then_fred", ({override}) => {
let other_users_appended;
override(buddy_list.$other_users_list[0], "append", (element) => {
other_users_appended = element;
});
override(padded_widget, "update_padding", noop);
override(buddy_list_presence, "update_indicators", noop);
activity_ui.redraw_user(alice.user_id);
assert.ok(other_users_appended.innerHTML.includes('data-user-id="1"'));
assert.ok(other_users_appended.innerHTML.includes("user-circle-active"));
activity_ui.redraw_user(fred.user_id);
assert.ok(other_users_appended.innerHTML.includes('data-user-id="2"'));
assert.ok(other_users_appended.innerHTML.includes("user-circle-active"));
});
test("insert_fred_then_alice_then_rename, both as users matching view", ({override}) => {
add_sub_and_set_as_current_narrow(rome_sub);
peer_data.set_subscribers(rome_sub.stream_id, [alice.user_id, fred.user_id]);
let users_matching_view_appended;
override(buddy_list.$users_matching_view_list[0], "append", (element) => {
users_matching_view_appended = element;
});
override(padded_widget, "update_padding", noop);
override(buddy_list_presence, "update_indicators", noop);
buddy_list_add_user_matching_view(alice.user_id, $alice_stub);
buddy_list_add_user_matching_view(fred.user_id, $fred_stub);
activity_ui.redraw_user(fred.user_id);
assert.ok(users_matching_view_appended.innerHTML.includes('data-user-id="2"'));
assert.ok(users_matching_view_appended.innerHTML.includes("user-circle-active"));
let inserted;
$fred_stub[0].before = (element) => {
inserted = element;
};
let fred_removed;
$fred_stub[0].remove = () => {
fred_removed = true;
};
activity_ui.redraw_user(alice.user_id);
assert.ok(inserted.innerHTML.includes('data-user-id="1"'));
assert.ok(inserted.innerHTML.includes("user-circle-active"));
// Next rename fred to Aaron.
const fred_with_new_name = {
email: fred.email,
user_id: fred.user_id,
full_name: "Aaron",
};
people.add_active_user(fred_with_new_name);
$alice_stub[0].before = (element) => {
inserted = element;
};
activity_ui.redraw_user(fred_with_new_name.user_id);
assert.ok(fred_removed);
assert.ok(users_matching_view_appended.innerHTML.includes('data-user-id="2"'));
// restore old Fred data
people.add_active_user(fred);
});
test("insert_fred_then_alice_then_rename, both as other users", ({override}) => {
add_sub_and_set_as_current_narrow(rome_sub);
peer_data.set_subscribers(rome_sub.stream_id, []);
let other_users_appended;
override(buddy_list.$other_users_list[0], "append", (element) => {
other_users_appended = element;
});
override(padded_widget, "update_padding", noop);
override(buddy_list_presence, "update_indicators", noop);
buddy_list_add_other_user(alice.user_id, $alice_stub);
buddy_list_add_other_user(fred.user_id, $fred_stub);
activity_ui.redraw_user(fred.user_id);
assert.ok(other_users_appended.innerHTML.includes('data-user-id="2"'));
assert.ok(other_users_appended.innerHTML.includes("user-circle-active"));
let inserted;
$fred_stub[0].before = (element) => {
inserted = element;
};
let fred_removed;
$fred_stub[0].remove = () => {
fred_removed = true;
};
activity_ui.redraw_user(alice.user_id);
assert.ok(inserted.innerHTML.includes('data-user-id="1"'));
assert.ok(inserted.innerHTML.includes("user-circle-active"));
// Next rename fred to Aaron.
const fred_with_new_name = {
email: fred.email,
user_id: fred.user_id,
full_name: "Aaron",
};
people.add_active_user(fred_with_new_name);
$alice_stub[0].before = (element) => {
inserted = element;
};
activity_ui.redraw_user(fred_with_new_name.user_id);
assert.ok(fred_removed);
assert.ok(other_users_appended.innerHTML.includes('data-user-id="2"'));
// restore old Fred data
people.add_active_user(fred);
});
test("insert_unfiltered_user_with_filter", () => {
// This test only tests that we do not explode when
// try to insert Fred into a list where he does not
// match the search filter.
const $user_filter = $("input.user-list-filter");
$user_filter.val("do-not-match-filter");
activity_ui.redraw_user(fred.user_id);
});
test("realm_presence_disabled", ({override}) => {
override(realm, "realm_presence_disabled", true);
activity_ui.redraw_user();
activity_ui.build_user_sidebar();
});
test("redraw_muted_user", () => {
muted_users.add_muted_user(mark.user_id);
activity_ui.redraw_user(mark.user_id);
});
test("update_presence_info", ({override}) => {
override(pm_list, "update_private_messages", noop);
override(buddy_list_presence, "update_indicators", noop);
override(realm, "realm_presence_disabled", false);
override(realm, "server_presence_ping_interval_seconds", 60);
override(realm, "server_presence_offline_threshold_seconds", 200);
let info = {
[me.user_id]: {
active_timestamp: 500,
idle_timestamp: 500,
},
};
const $alice_li = $.create("alice stub");
buddy_list_add_user_matching_view(alice.user_id, $alice_li);
let inserted;
override(buddy_list, "insert_or_move", () => {
inserted = true;
});
presence.presence_info.delete(me.user_id);
activity_ui.update_presence_info(info);
assert.ok(inserted);
assert.deepEqual(presence.presence_info.get(me.user_id).status, "active");
info = {
[alice.user_id]: {
active_timestamp: 500,
idle_timestamp: 500,
},
};
presence.presence_info.delete(alice.user_id);
activity_ui.update_presence_info(info);
assert.ok(inserted);
const expected = {status: "active", last_active: 500};
assert.deepEqual(presence.presence_info.get(alice.user_id), expected);
// Test invalid and inaccessible user IDs.
const invalid_user_id = 99;
info = {
[invalid_user_id]: {
active_timestamp: 500,
idle_timestamp: 500,
},
};
activity_ui.update_presence_info(info);
assert.equal(presence.presence_info.get(invalid_user_id), undefined);
const inaccessible_user_id = 10;
settings_data.user_can_access_all_other_users = () => false;
const inaccessible_user = people.make_user(
inaccessible_user_id,
"user10@zulipdev.com",
"Unknown user",
);
people._add_user(inaccessible_user);
info = {
[inaccessible_user_id]: {
active_timestamp: 500,
idle_timestamp: 500,
},
};
activity_ui.update_presence_info(info);
assert.equal(presence.presence_info.get(inaccessible_user_id), undefined);
});
test("check_should_redraw_new_user", ({override}) => {
presence.presence_info.set(9999, {status: "active"});
// A user that wasn't yet known, but has presence info should be redrawn
// when being added.
assert.equal(activity_ui.check_should_redraw_new_user(9999), true);
// We don't even build the user sidebar if realm_presence_disabled is true,
// so nothing to redraw.
override(realm, "realm_presence_disabled", true);
assert.equal(activity_ui.check_should_redraw_new_user(9999), false);
override(realm, "realm_presence_disabled", false);
// A new user that didn't have presence info should not be redrawn.
assert.equal(activity_ui.check_should_redraw_new_user(99999), false);
});

View File

@@ -0,0 +1,91 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const _document = {
hasFocus() {
return true;
},
};
set_global("document", _document);
mock_esm("../src/electron_bridge", {
electron_bridge: {},
});
const activity = zrequire("activity");
run_test("activity.initialize", ({override}) => {
/*
VERSION THAT WAS ORIGINALLY TESTED:
export function initialize(): void {
$(document).on("mousemove", () => {
set_new_user_input(true);
});
$(window).on(
"focus keydown mousedown mousemove touchmove touchstart wheel",
mark_client_active,
);
if (client_is_active) {
mark_client_idle_later();
}
}
*/
const $document_stub = $("document-stub");
const $window_stub = $("window-stub");
override(window, "to_$", () => $window_stub);
override(document, "to_$", () => $document_stub);
activity.set_new_user_input(false);
assert.equal(activity.new_user_input, false);
activity.initialize();
assert.equal(
$window_stub.get_on_handler("focus keydown mousedown mousemove touchmove touchstart wheel"),
activity.mark_client_active,
);
$document_stub.trigger("mousemove"); // should be set_new_user_input(true)
assert.equal(activity.new_user_input, true);
});
run_test("mark_client_active and mark_client_idle", ({override_rewire}) => {
// See the activity.initialize test above to verify
// how these helper functions are reliably wired to
// onActive and onIdle handlers on the browser window
// object.
let server_calls = 0;
override_rewire(activity, "send_presence_to_server", () => {
server_calls += 1;
});
// Use this to put us into an effective idle state, but then
// after this, the code does whatever mark_client_active and
// mark_client_idle tell it to do.
activity.clear_for_testing();
assert.equal(activity.compute_active_status(), "idle");
// This is a pretty simple API.
activity.mark_client_active();
assert.equal(activity.compute_active_status(), "active");
assert.equal(server_calls, 1);
activity.mark_client_idle();
assert.equal(activity.compute_active_status(), "idle");
assert.equal(server_calls, 1);
activity.mark_client_active();
assert.equal(activity.compute_active_status(), "active");
assert.equal(server_calls, 2);
activity.mark_client_idle();
assert.equal(activity.compute_active_status(), "idle");
assert.equal(server_calls, 2);
});

View File

@@ -0,0 +1,100 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {noop, run_test} = require("./lib/test.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
mock_esm("../src/electron_bridge");
mock_esm("../src/watchdog", {
check_for_unsuspend: noop,
});
const channel = mock_esm("../src/channel");
const presence = mock_esm("../src/presence");
set_global("document", {hasFocus: () => true});
const activity = zrequire("activity");
run_test("send_presence_to_server (spectator)", ({override}) => {
override(page_params, "is_spectator", true);
activity.send_presence_to_server();
});
run_test("send_presence_to_server (no redraw)", ({override}) => {
let success_function;
assert.equal(activity.new_user_input, true);
override(channel, "post", ({url, data, success}) => {
success_function = success;
assert.equal(url, "/json/users/me/presence");
assert.deepEqual(data, {
status: "active",
ping_only: true,
new_user_input: true,
last_update_id: 42,
});
});
override(presence, "presence_last_update_id", 42);
activity.send_presence_to_server();
const response = {
presences: {},
msg: "",
result: "success",
server_timestamp: 0,
presence_last_update_id: -1,
};
success_function(response);
assert.equal(activity.new_user_input, false);
});
run_test("send_presence_to_server (redraw)", ({override}) => {
let success_function;
activity.set_new_user_input(true);
assert.equal(activity.new_user_input, true);
override(channel, "post", ({url, data, success}) => {
success_function = success;
assert.equal(url, "/json/users/me/presence");
assert.deepEqual(data, {
status: "active",
ping_only: false,
new_user_input: true,
last_update_id: 77,
});
});
let num_redraw_callbacks = 0;
function redraw() {
num_redraw_callbacks += 1;
}
override(presence, "presence_last_update_id", 77);
activity.send_presence_to_server(redraw);
const response = {
presences: {},
msg: "",
result: "success",
server_timestamp: 9999,
presence_last_update_id: 78,
};
override(presence, "set_info", (presences, server_timestamp, presence_last_update_id) => {
assert.deepEqual(presences, {});
assert.deepEqual(server_timestamp, 9999);
assert.deepEqual(presence_last_update_id, 78);
});
success_function(response);
assert.equal(activity.new_user_input, false);
assert.equal(num_redraw_callbacks, 1);
});

View File

@@ -0,0 +1,118 @@
"use strict";
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {noop, run_test} = require("./lib/test.cjs");
const activity = mock_esm("../src/activity");
const buddy_list_presence = mock_esm("../src/buddy_list_presence");
const scroll_util = mock_esm("../src/scroll_util");
const pm_list = mock_esm("../src/pm_list");
const util = mock_esm("../src/util");
mock_esm("../src/sidebar_header_sticky_shadow", {initialize() {}});
const state_data = zrequire("state_data");
const activity_ui = zrequire("activity_ui");
run_test("redraw", ({override, override_rewire}) => {
override_rewire(activity_ui, "build_user_sidebar", noop);
activity_ui.set_cursor_and_filter();
override(pm_list, "update_private_messages", noop);
override(buddy_list_presence, "update_indicators", noop);
activity_ui.redraw();
});
run_test("defer buddy list redraw while a user card is open", ({override, override_rewire}) => {
const realm = make_realm();
state_data.set_realm(realm);
override(realm, "realm_presence_disabled", false);
override(pm_list, "update_private_messages", noop);
override(buddy_list_presence, "update_indicators", noop);
activity_ui.set_cursor_and_filter();
activity_ui.set_user_card_open(true);
assert.equal(activity_ui.build_user_sidebar(), undefined);
activity_ui.redraw_user(1);
let build_count = 0;
override_rewire(activity_ui, "build_user_sidebar", () => {
build_count += 1;
return undefined;
});
activity_ui.set_user_card_open(false);
assert.equal(build_count, 1);
activity_ui.set_user_card_open(true);
activity_ui.set_user_card_open(false);
assert.equal(build_count, 1);
});
run_test("initialize", ({override, override_rewire}) => {
const realm = make_realm();
state_data.set_realm(realm);
override(realm, "server_presence_ping_interval_seconds", 42);
override_rewire(activity_ui, "set_cursor_and_filter", noop);
override_rewire(activity_ui, "build_user_sidebar", noop);
override(scroll_util, "get_scroll_element", (selector) => {
assert.equal(selector.selector, "#buddy_list_wrapper");
return {
on(action, callback) {
assert.equal(action, "scroll");
// We don't test the actual logic of the scroll
// handler here. We did our job to set it up.
assert.ok(callback !== undefined);
},
};
});
let expected_redraw;
let num_presence_calls = 0;
override(activity, "send_presence_to_server", (redraw) => {
num_presence_calls += 1;
assert.equal(redraw, expected_redraw);
});
let f_to_call_periodically;
override(util, "call_function_periodically", (f, delay) => {
f_to_call_periodically = f;
assert.equal(delay, 42000);
});
let my_user_id;
function narrow_by_user_id(user_id) {
my_user_id = user_id;
}
activity_ui.initialize({narrow_by_user_id});
assert.equal(my_user_id, undefined);
activity_ui.get_narrow_by_user_id_function_for_test_code()(1);
assert.equal(my_user_id, 1);
// Simulate the subsequent calls to
// activity.send_presence_to_server.
expected_redraw = activity_ui.redraw;
assert.equal(num_presence_calls, 1);
f_to_call_periodically();
f_to_call_periodically();
f_to_call_periodically();
f_to_call_periodically();
f_to_call_periodically();
assert.equal(num_presence_calls, 6);
f_to_call_periodically();
f_to_call_periodically();
f_to_call_periodically();
f_to_call_periodically();
f_to_call_periodically();
assert.equal(num_presence_calls, 11);
});

View File

@@ -0,0 +1,208 @@
"use strict";
const assert = require("node:assert/strict");
const {make_user} = require("./lib/example_user.cjs");
const {set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
set_global("page_params", {
is_spectator: false,
});
const params = {
alert_words: [
"alertone",
"alerttwo",
"alertthree",
"al*rt.*s",
".+",
"emoji",
"FD&C",
"<3",
">8",
"5'11\"",
],
};
const people = zrequire("people");
const alert_words = zrequire("alert_words");
alert_words.initialize(params);
people.add_active_user(
make_user({
email: "tester@zulip.com",
full_name: "Tester von Tester",
user_id: 42,
}),
);
people.initialize_current_user(42);
const regular_message = {
sender_id: 15,
content: "<p>a message</p>",
};
const own_message = {
sender_id: 42,
content: "<p>hey this message alertone</p>",
alerted: true,
};
const other_message = {
sender_id: 15,
content: "<p>another alertone message</p>",
alerted: true,
};
const caps_message = {
sender_id: 15,
content: "<p>another ALERTtwo message</p>",
alerted: true,
};
const alertwordboundary_message = {
sender_id: 15,
content: "<p>another alertthreemessage</p>",
alerted: false,
};
const multialert_message = {
sender_id: 15,
content: "<p>another emoji alertone and then alerttwo</p>",
alerted: true,
};
const unsafe_word_message = {
sender_id: 15,
content: "<p>gotta al*rt.*s all</p>",
alerted: true,
};
const alert_in_url_message = {
sender_id: 15,
content: "<p>http://www.google.com/alertone/me</p>",
alerted: true,
};
const question_word_message = {
sender_id: 15,
content: "<p>still alertone? me</p>",
alerted: true,
};
const typo_word_message = {
sender_id: 15,
content: "<p>alertones alerttwo alerttwo alertthreez</p>",
alerted: true,
};
const alert_domain_message = {
sender_id: 15,
content:
'<p>now with link <a href="http://www.alerttwo.us/foo/bar" target="_blank" title="http://www.alerttwo.us/foo/bar">www.alerttwo.us/foo/bar</a></p>',
alerted: true,
};
// This test ensure we are not mucking up rendered HTML content.
const message_with_emoji = {
sender_id: 15,
content:
'<p>I <img alt=":heart:" class="emoji" src="/static/generated/emoji/images/emoji/unicode/2764.png" title="heart"> emoji!</p>',
alerted: true,
};
run_test("notifications", () => {
assert.ok(!alert_words.notifies(regular_message));
assert.ok(!alert_words.notifies(own_message));
assert.ok(alert_words.notifies(other_message));
assert.ok(alert_words.notifies(caps_message));
assert.ok(!alert_words.notifies(alertwordboundary_message));
assert.ok(alert_words.notifies(multialert_message));
assert.ok(alert_words.notifies(unsafe_word_message));
assert.ok(alert_words.notifies(alert_domain_message));
assert.ok(alert_words.notifies(message_with_emoji));
});
run_test("munging", () => {
alert_words.initialize(params);
assert.deepEqual(alert_words.get_word_list(), [
{word: "alertthree"},
{word: "alertone"},
{word: "alerttwo"},
{word: "al*rt.*s"},
{word: "emoji"},
{word: `5'11"`},
{word: "FD&C"},
{word: ".+"},
{word: "<3"},
{word: ">8"},
]);
let saved_content = regular_message.content;
alert_words.process_message(regular_message);
assert.equal(saved_content, regular_message.content);
saved_content = alertwordboundary_message.content;
alert_words.process_message(alertwordboundary_message);
assert.equal(alertwordboundary_message.content, saved_content);
function assert_transform(message, expected_new_content) {
const msg = {...message};
alert_words.process_message(msg);
assert.equal(msg.content, expected_new_content);
}
assert_transform(
other_message,
"<p>another <span class='alert-word'>alertone</span> message</p>",
);
assert_transform(
caps_message,
"<p>another <span class='alert-word'>ALERTtwo</span> message</p>",
);
assert_transform(
multialert_message,
"<p>another <span class='alert-word'>emoji</span> <span class='alert-word'>alertone</span> and then <span class='alert-word'>alerttwo</span></p>",
);
assert_transform(
unsafe_word_message,
"<p>gotta <span class='alert-word'>al*rt.*s</span> all</p>",
);
assert_transform(alert_in_url_message, "<p>http://www.google.com/alertone/me</p>");
assert_transform(
question_word_message,
"<p>still <span class='alert-word'>alertone</span>? me</p>",
);
assert_transform(
typo_word_message,
"<p>alertones <span class='alert-word'>alerttwo</span> <span class='alert-word'>alerttwo</span> alertthreez</p>",
);
assert_transform(
alert_domain_message,
'<p>now with link <a href="http://www.alerttwo.us/foo/bar" target="_blank" title="http://www.alerttwo.us/foo/bar">www.<span class=\'alert-word\'>alerttwo</span>.us/foo/bar</a></p>',
);
assert_transform(
message_with_emoji,
'<p>I <img alt=":heart:" class="emoji" src="/static/generated/emoji/images/emoji/unicode/2764.png" title="heart"> <span class=\'alert-word\'>emoji</span>!</p>',
);
assert_transform(
{
sender_email: "another@zulip.com",
content: `<p>FD&amp;C &lt;3 &gt;8 5'11" 5&#39;11&quot;</p>`,
alerted: true,
},
`<p><span class='alert-word'>FD&amp;C</span> <span class='alert-word'>&lt;3</span> <span class='alert-word'>&gt;8</span> <span class='alert-word'>5'11"</span> <span class='alert-word'>5&#39;11&quot;</span></p>`,
);
});
run_test("basic get/set operations", () => {
alert_words.initialize({alert_words: []});
assert.ok(!alert_words.has_alert_word("breakfast"));
assert.ok(!alert_words.has_alert_word("lunch"));
alert_words.set_words(["breakfast", "lunch"]);
assert.ok(alert_words.has_alert_word("breakfast"));
assert.ok(alert_words.has_alert_word("lunch"));
assert.ok(!alert_words.has_alert_word("dinner"));
});

View File

@@ -0,0 +1,104 @@
"use strict";
const assert = require("node:assert/strict");
const {$t} = require("./lib/i18n.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const channel = mock_esm("../src/channel");
const alert_words = zrequire("alert_words");
const alert_words_ui = zrequire("alert_words_ui");
const banners = mock_esm("../src/banners");
alert_words.initialize({
alert_words: ["foo", "bar"],
});
run_test("rerender_alert_words_ui", ({mock_template}) => {
let list_widget_create_called = false;
alert_words_ui.reset();
mock_esm("../src/list_widget", {
create(_container, words, opts) {
assert.deepEqual(words, [{word: "bar"}, {word: "foo"}]);
for (const word of words) {
opts.modifier_html(word);
}
list_widget_create_called = true;
},
generic_sort_functions: noop,
});
mock_template("settings/alert_word_settings_item.hbs", true, (args, html) => {
assert.ok(["foo", "bar"].includes(args.alert_word.word));
// do a super easy sanity check
assert.ok(html.includes("alert_word_listing"));
return "<alert-word-settings-item-stub>";
});
assert.equal(alert_words_ui.loaded, false);
assert.equal(list_widget_create_called, false);
// Invoke list_widget.create indirectly via these calls.
alert_words_ui.rerender_alert_words_ui();
alert_words_ui.set_up_alert_words();
assert.equal(alert_words_ui.loaded, true);
assert.equal(list_widget_create_called, true);
});
run_test("remove_alert_word", () => {
alert_words_ui.set_up_alert_words();
const $word_list = $("#alert-words-table");
const remove_func = $word_list.get_on_handler("click", ".remove-alert-word");
const $remove_alert_word = $(".remove-alert-word");
const $list_item = $("tr.alert-word-item");
const $val_item = $("span.value");
$val_item.text($t({defaultMessage: "zot"}));
$remove_alert_word.set_parents_result("tr", $list_item);
$list_item.set_find_results(".value", $val_item);
const event = {
currentTarget: ".remove-alert-word",
};
let success_func;
let fail_func;
channel.del = (opts) => {
assert.equal(opts.url, "/json/users/me/alert_words");
assert.deepEqual(opts.data, {alert_words: '["translated: zot"]'});
success_func = opts.success;
fail_func = opts.error;
};
remove_func(event);
const $alert_word_status_banner = $(".alert-word-status-banner");
const $alert_word_status_banner_label = $(".alert-word-status-banner .banner-label");
banners.open = (banner, _container) => {
$alert_word_status_banner.addClass(`banner-${banner.intent}`);
$alert_word_status_banner_label.text(banner.label);
};
// test failure
fail_func();
assert.ok($alert_word_status_banner.hasClass("banner-danger"));
assert.equal(
$alert_word_status_banner_label.text(),
`translated: Error removing alert word <b>translated: zot</b>!`,
);
// test success
success_func();
assert.ok($alert_word_status_banner.hasClass("banner-success"));
assert.equal(
$alert_word_status_banner_label.text(),
`translated: Alert word <b>translated: zot</b> removed successfully!`,
);
});

View File

@@ -0,0 +1,22 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const background_task = zrequire("background_task");
run_test("background task runs", () => {
let did_run = false;
let async_did_run = false;
async function task() {
did_run = true;
await Promise.resolve("ok");
async_did_run = true;
}
background_task.run_async_function_without_await(task);
assert.equal(did_run, true);
assert.equal(async_did_run, false);
});

View File

@@ -0,0 +1,62 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const blueslip_stacktrace = zrequire("blueslip_stacktrace");
run_test("clean_path", () => {
// Local file
assert.strictEqual(
blueslip_stacktrace.clean_path("webpack:///web/src/upload.ts"),
"/web/src/upload.ts",
);
// Third party library (jQuery)
assert.strictEqual(
blueslip_stacktrace.clean_path(
"webpack:///.-npm-cache/de76fb6f582a29b053274f9048b6158091351048/node_modules/jquery/dist/jquery.js",
),
"jquery/dist/jquery.js",
);
// Third party library (underscore)
assert.strictEqual(
blueslip_stacktrace.clean_path(
"webpack:///.-npm-cache/de76fb6f582a29b053274f9048b…58091351048/node_modules/underscore/underscore.js",
),
"underscore/underscore.js",
);
});
run_test("clean_function_name", () => {
assert.deepEqual(blueslip_stacktrace.clean_function_name(undefined), undefined);
// Local file
assert.deepEqual(
blueslip_stacktrace.clean_function_name("Object../web/src/upload.ts.exports.options"),
{
scope: "Object../web/src/upload.ts.exports.",
name: "options",
},
);
// Third party library (jQuery)
assert.deepEqual(blueslip_stacktrace.clean_function_name("mightThrow"), {
scope: "",
name: "mightThrow",
});
// Third party library (underscore)
assert.deepEqual(
blueslip_stacktrace.clean_function_name(
"Function.../zulip-npm-cache/de76fb6f582a29b053274f…es/underscore/underscore.js?3817._.each._.forEach",
),
{
scope: "Function.../zulip-npm-cache/de76fb6f582a29b053274f…es/underscore/underscore.js?3817._.each._.",
name: "forEach",
},
);
});

252
web/tests/bot_data.test.cjs Normal file
View File

@@ -0,0 +1,252 @@
"use strict";
const assert = require("node:assert/strict");
const {make_user} = require("./lib/example_user.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const bot_data = zrequire("bot_data");
const people = zrequire("people");
// Bot types can be found in zerver/models/users.py
// UserProfile class or zerver/openapi/zulip.yaml
const me = make_user({
email: "me@zulip.com",
full_name: "Me Myself",
user_id: 2,
});
const fred = make_user({
email: "fred@zulip.com",
full_name: "Fred Frederickson",
user_id: 3,
});
const bot_0_user = make_user({
email: "bot0@zulip.com",
full_name: "Bot 0",
user_id: 42,
is_bot: true,
bot_type: 1, // DEFAULT_BOT
bot_owner_id: 4,
});
const webhook_bot_user = make_user({
email: "outgoingwebhook@zulip.com",
full_name: "Outgoing webhook",
user_id: 314,
is_bot: true,
bot_type: 3, // OUTGOING_WEBHOOK_BOT
bot_owner_id: 5,
});
const test_bot_user = make_user({
email: "bot1@zulip.com",
full_name: "Bot 1",
user_id: 43,
// Default bot
bot_type: 1,
is_bot: true,
bot_owner_id: 6,
});
const test_embedded_bot_user = make_user({
email: "embedded-bot@zulip.com",
full_name: "Embedded bot 1",
user_id: 143,
is_bot: true,
bot_type: 4, // EMBEDDED_BOT
bot_owner_id: 7,
});
const test_bot_1_user = make_user({
email: "bot1@zulip.com",
full_name: "Bot 1",
user_id: 44,
// Default bot
bot_type: 1,
is_bot: true,
bot_owner_id: me.user_id,
});
const test_bot_2_user = make_user({
email: "bot2@zulip.com",
full_name: "Bot 1",
user_id: 45,
// Default bot
bot_type: 1,
is_bot: true,
bot_owner_id: me.user_id,
});
const test_bot_3_user = make_user({
email: "bot3@zulip.com",
full_name: "Bot 1",
user_id: 46,
// Default bot
bot_type: 1,
is_bot: true,
bot_owner_id: fred.user_id,
});
const bot_data_params = {
realm_bots: [
{
default_all_public_streams: true,
default_events_register_stream: "register stream 42",
default_sending_stream: "sending stream 42",
user_id: 42,
services: [],
extra: "This field should be ignored",
},
{
default_all_public_streams: true,
default_events_register_stream: "register stream 314",
default_sending_stream: "sending stream 314",
user_id: 314,
services: [{base_url: "http://foo.com", interface: 1, token: "basictoken12345"}],
extra: "This field should be ignored",
},
],
};
function test(label, f) {
run_test(label, ({override}) => {
people.add_active_user(me);
people.add_active_user(bot_0_user);
people.add_active_user(webhook_bot_user);
people.add_active_user(test_bot_user);
people.add_active_user(test_embedded_bot_user);
people.add_active_user(test_bot_1_user);
people.add_active_user(test_bot_2_user);
people.add_active_user(test_bot_3_user);
people.initialize_current_user(me.user_id);
bot_data.initialize(bot_data_params);
// Our startup logic should have added Bot 0 from page_params.
assert.equal(bot_data.get(42).full_name, "Bot 0");
assert.equal(bot_data.get(314).full_name, "Outgoing webhook");
f({override});
});
}
test("test_basics", () => {
people.add_active_user(fred);
const test_bot = {
default_all_public_streams: true,
default_events_register_stream: "register stream 43",
default_sending_stream: "sending stream 43",
user_id: 43,
services: [],
extra: "This field should be ignored",
};
const test_embedded_bot = {
default_all_public_streams: true,
default_events_register_stream: "register stream 143",
default_sending_stream: "sending stream 143",
user_id: 143,
services: [
{
config_data: {key: "12345678"},
service_name: "giphy",
},
],
extra: "This field should be ignored",
};
(function test_add() {
bot_data.add(test_bot);
const bot = bot_data.get(43);
assert.equal(1, bot.bot_type);
assert.equal(true, bot.default_all_public_streams);
assert.equal("register stream 43", bot.default_events_register_stream);
assert.equal("sending stream 43", bot.default_sending_stream);
assert.equal("bot1@zulip.com", bot.email);
assert.equal("Bot 1", bot.full_name);
assert.equal(true, bot.is_active);
assert.equal(6, bot.owner_id);
assert.equal(43, bot.user_id);
assert.equal(undefined, bot.extra);
})();
(function test_embedded_bot_update() {
bot_data.add(test_embedded_bot);
const bot_id = 143;
const services = bot_data.get_services(bot_id);
assert.equal("12345678", services[0].config_data.key);
bot_data.update(bot_id, {
...test_embedded_bot,
services: [{config_data: {key: "87654321"}, service_name: "embedded bot service"}],
});
assert.equal("87654321", services[0].config_data.key);
assert.equal("embedded bot service", services[0].service_name);
})();
(function test_all_user_ids() {
const all_ids = bot_data.all_user_ids();
all_ids.sort();
assert.deepEqual(all_ids, [143, 314, 42, 43]);
})();
(function test_delete() {
let bot;
bot_data.add({...test_bot, is_active: true});
bot = bot_data.get(43);
assert.equal("Bot 1", bot.full_name);
assert.ok(bot.is_active);
bot_data.del(43);
bot = bot_data.get(43);
assert.equal(bot, undefined);
})();
(function test_get_all_bots_for_current_user() {
bot_data.add({...test_bot, user_id: 44, owner_id: me.user_id, is_active: true});
bot_data.add({
...test_bot,
user_id: 45,
email: "bot2@zulip.com",
owner_id: me.user_id,
is_active: true,
});
bot_data.add({
...test_bot,
user_id: 46,
email: "bot3@zulip.com",
owner_id: fred.user_id,
is_active: true,
});
const bots = bot_data.get_all_bots_for_current_user();
assert.equal(bots.length, 2);
assert.equal(bots[0].email, "bot1@zulip.com");
assert.equal(bots[1].email, "bot2@zulip.com");
})();
(function test_get_number_of_bots_owned_by_user() {
const bots_owned_by_user = bot_data.get_all_bots_owned_by_user(3);
assert.equal(bots_owned_by_user[0].email, "bot3@zulip.com");
})();
});
test("get_all_bots_ids_for_current_user", () => {
// Ensure bots owned by others are not included
bot_data.add({
default_all_public_streams: true,
default_events_register_stream: "register stream test",
default_sending_stream: "sending stream test",
user_id: 44,
services: [],
});
bot_data.add({
default_all_public_streams: true,
default_events_register_stream: "register stream another",
default_sending_stream: "sending stream another",
user_id: 46,
services: [],
});
const my_bot_ids = bot_data.get_all_bots_ids_for_current_user();
assert.deepEqual(my_bot_ids, [44]);
});

View File

@@ -0,0 +1,235 @@
"use strict";
const assert = require("node:assert/strict");
const {JSDOM} = require("jsdom");
const {set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {window} = new JSDOM("");
// Only assign globals our library actually reads (document and
// getComputedStyle). `global.HTMLElement` is intentionally NOT
// overwritten: index.cjs already points it at its own JSDOM's
// HTMLElement, and later tests such as `compose_paste.test.cjs`
// rely on `instanceof HTMLElement` matching elements parsed via
// their DOMParser. Routing the assignments through `set_global`
// ensures the globals are restored after this test, since later
// tests in the same process can be sensitive to seeing a real
// `document.createElement` (e.g., it makes simplebar's `canUseDOM`
// check true, causing it to call `window.addEventListener` at
// module load time, which the test environment doesn't provide).
set_global("document", window.document);
set_global("getComputedStyle", window.getComputedStyle.bind(window));
// `setPointerCapture` / `releasePointerCapture` are not implemented by
// jsdom; stub them on this test's own HTMLElement prototype so our
// pointer event handlers can call them without throwing. We avoid
// stubbing on `global.HTMLElement` since that prototype is shared
// with other tests.
window.HTMLElement.prototype.setPointerCapture = function () {};
window.HTMLElement.prototype.releasePointerCapture = function () {};
const box_resize = zrequire("box_resize");
function make_box() {
const box = document.createElement("div");
// Provide a predictable starting rect so tests don't depend on
// jsdom's layout (which is a no-op).
let current_rect = {width: 100, height: 80};
box.getBoundingClientRect = () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: current_rect.width,
bottom: current_rect.height,
width: current_rect.width,
height: current_rect.height,
});
box.set_rect = (width, height) => {
current_rect = {width, height};
};
return box;
}
function fire(handle, type, {clientX = 0, clientY = 0, pointerId = 1} = {}) {
const event = new window.Event(type, {bubbles: true, cancelable: true});
Object.assign(event, {clientX, clientY, pointerId});
handle.dispatchEvent(event);
return event;
}
run_test("make_resizable creates one handle per direction with expected class", () => {
const box = make_box();
box_resize.make_resizable(box, ["top", "right", "bottom_left"]);
assert.equal(box.children.length, 3);
const classes = [...box.children].map((h) => h.className);
assert.ok(classes[0].includes("resizable-box-handle-top"));
assert.ok(classes[1].includes("resizable-box-handle-right"));
assert.ok(classes[2].includes("resizable-box-handle-bottom-left"));
// Underscores in the direction are converted to hyphens for the class.
assert.ok(!classes[2].includes("bottom_left"));
});
run_test("cleanup removes the handles it added", () => {
const box = make_box();
const existing_child = document.createElement("span");
box.append(existing_child);
const cleanup = box_resize.make_resizable(box, ["right", "bottom"]);
assert.equal(box.children.length, 3);
cleanup();
assert.equal(box.children.length, 1);
assert.equal(box.firstElementChild, existing_child);
});
run_test("right handle grows width by 2 * dx and does not touch height", () => {
const box = make_box();
box.set_rect(100, 80);
box_resize.make_resizable(box, ["right"]);
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 50, clientY: 40});
fire(handle, "pointermove", {clientX: 60, clientY: 45});
// Dragging right by 10: box grows by 20 (10 on each side, centered).
assert.equal(box.style.width, "120px");
// `height` is left untouched by an edge-only handle.
assert.equal(box.style.height, "");
});
run_test("left handle grows width when pointer moves leftward", () => {
const box = make_box();
box.set_rect(100, 80);
box_resize.make_resizable(box, ["left"]);
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 50, clientY: 40});
// Dragging the left edge 15px to the left: the box grows symmetrically
// by 30px total.
fire(handle, "pointermove", {clientX: 35, clientY: 40});
assert.equal(box.style.width, "130px");
});
run_test("top handle grows height when pointer moves upward", () => {
const box = make_box();
box.set_rect(100, 80);
box_resize.make_resizable(box, ["top"]);
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 50, clientY: 40});
fire(handle, "pointermove", {clientX: 50, clientY: 30});
// Dragging the top edge up by 10 => height grows by 20.
assert.equal(box.style.height, "100px");
assert.equal(box.style.width, "");
});
run_test("corner handle grows both dimensions with correct signs", () => {
const box = make_box();
box.set_rect(200, 100);
box_resize.make_resizable(box, ["top_left"]);
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 50, clientY: 50});
// Move 5 left, 8 up: both dx_sign and dy_sign are -1 for top_left,
// so width grows by 2*5 = 10 and height grows by 2*8 = 16.
fire(handle, "pointermove", {clientX: 45, clientY: 42});
assert.equal(box.style.width, "210px");
assert.equal(box.style.height, "116px");
});
run_test("on_resize callback fires once per pointermove after pointerdown", () => {
const box = make_box();
box.set_rect(100, 80);
let call_count = 0;
box_resize.make_resizable(box, ["bottom_right"], () => {
call_count += 1;
});
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 0, clientY: 0});
assert.equal(call_count, 0);
fire(handle, "pointermove", {clientX: 5, clientY: 5});
fire(handle, "pointermove", {clientX: 10, clientY: 10});
assert.equal(call_count, 2);
fire(handle, "pointerup", {clientX: 10, clientY: 10});
fire(handle, "pointermove", {clientX: 20, clientY: 20});
assert.equal(call_count, 2);
});
run_test("pointerup stops subsequent pointermove events from resizing", () => {
const box = make_box();
box.set_rect(100, 80);
box_resize.make_resizable(box, ["right"]);
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 50, clientY: 40});
fire(handle, "pointermove", {clientX: 60, clientY: 40});
assert.equal(box.style.width, "120px");
fire(handle, "pointerup", {clientX: 60, clientY: 40});
fire(handle, "pointermove", {clientX: 200, clientY: 40});
// Width should not have changed after pointerup.
assert.equal(box.style.width, "120px");
});
run_test("pointercancel stops subsequent pointermove events from resizing", () => {
const box = make_box();
box.set_rect(100, 80);
box_resize.make_resizable(box, ["right"]);
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 50, clientY: 40});
fire(handle, "pointermove", {clientX: 60, clientY: 40});
assert.equal(box.style.width, "120px");
// The browser can take the gesture away at any time; we must
// tear the listeners down or subsequent moves would keep resizing.
fire(handle, "pointercancel", {clientX: 60, clientY: 40});
fire(handle, "pointermove", {clientX: 200, clientY: 40});
assert.equal(box.style.width, "120px");
});
run_test("resize clamps to CSS min and max bounds during a single drag", () => {
const box = make_box();
box.set_rect(100, 80);
// Jsdom does not resolve the element's real computed style from a
// stylesheet, so we stub getComputedStyle to return the min/max
// bounds we want to test against. The library only calls it on
// `box`, so we don't need a passthrough.
const original_get_computed_style = global.getComputedStyle;
global.getComputedStyle = () => ({
minWidth: "60px",
maxWidth: "140px",
minHeight: "0px",
maxHeight: "none",
});
try {
box_resize.make_resizable(box, ["right"]);
const handle = box.firstElementChild;
fire(handle, "pointerdown", {clientX: 50, clientY: 40});
// Overshoot the max: raw would be 100 + 2*100 = 300, clamp to 140.
fire(handle, "pointermove", {clientX: 150, clientY: 40});
assert.equal(box.style.width, "140px");
// Pull back within bounds: raw is 100 + 2*10 = 120, no clamp.
fire(handle, "pointermove", {clientX: 60, clientY: 40});
assert.equal(box.style.width, "120px");
// Undershoot the min: raw would be 100 - 2*40 = 20, clamp to 60.
fire(handle, "pointermove", {clientX: 10, clientY: 40});
assert.equal(box.style.width, "60px");
} finally {
global.getComputedStyle = original_get_computed_style;
}
});

View File

@@ -0,0 +1,77 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
window.location.hash = "#bogus";
const browser_history = zrequire("browser_history");
const {initialize_user_settings} = zrequire("user_settings");
const user_settings = {};
initialize_user_settings({user_settings});
function test(label, f) {
run_test(label, (helpers) => {
helpers.override(user_settings, "web_home_view", "recent");
window.location.hash = "#bogus";
browser_history.clear_for_testing();
f(helpers);
});
}
test("basics", () => {
const hash1 = "#settings/profile";
const hash2 = "#narrow/is/dm";
browser_history.go_to_location(hash1);
assert.equal(window.location.hash, hash1);
browser_history.update(hash2);
assert.equal(window.location.hash, hash2);
assert.equal(browser_history.old_hash(), hash1);
const was_internal_change = browser_history.save_old_hash();
assert.ok(was_internal_change);
assert.equal(browser_history.old_hash(), hash2);
});
test("update with same hash", () => {
const hash = "#keyboard-shortcuts";
browser_history.update(hash);
assert.equal(window.location.hash, hash);
browser_history.update(hash);
assert.equal(window.location.hash, hash);
});
test("error for bad hashes", () => {
const hash = "bogus";
blueslip.expect("error", "programming error: prefix hashes with #");
browser_history.update(hash);
});
test("update internal hash if required", () => {
const hash = "#test/hash";
browser_history.update_hash_internally_if_required(hash);
assert.equal(window.location.hash, hash);
// Calling again with the same hash should be a no-op.
// If update() were called, it would log a blueslip.info
// for the redundant hash, so verify no new info appeared.
const info_count = blueslip.get_test_logs("info").length;
browser_history.update_hash_internally_if_required(hash);
assert.equal(blueslip.get_test_logs("info").length, info_count);
});
test("web-public view hash restore", () => {
browser_history.update("#");
assert.equal(window.location.hash, "");
const new_hash = "#narrow/is/dm";
browser_history.update(new_hash);
assert.equal(window.location.hash, new_hash);
browser_history.return_to_web_public_hash();
assert.equal(window.location.hash, "#recent");
});

View File

@@ -0,0 +1,35 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
mock_esm("../generated/supported_browser_regex.ts", {
// Snapshot of generated regex on 2025-12-17
baselineBrowserRegex:
/Edge?\/(1{2}[1-9]|1[2-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Firefox\/(1{2}[4-9]|1[2-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Chrom(ium|e)\/(1{2}[1-9]|1[2-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|(Maci|X1{2}).+ Version\/(16\.([4-9]|\d{2,})|(1[7-9]|[2-9]\d|\d{3,})\.\d+)([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/(9[7-9]|\d{3,})\.\d+\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(16[._]([4-9]|\d{2,})|(1[7-9]|[2-9]\d|\d{3,})[._]\d+)([._]\d+|)|Android:?[ /-](14[2-9]|1[5-9]\d|[2-9]\d{2}|\d{4,})(\.\d+|)(\.\d+|)|Mobile Safari.+OPR\/([89]\d|\d{3,})\.\d+\.\d+|Android.+Firefox\/(14[4-9]|1[5-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Android.+Chrom(ium|e)\/(14[2-9]|1[5-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|SamsungBrowser\/(2[2-9]|[3-9]\d|\d{3,})\.\d+/,
allBrowsersRegex:
/IE (5\.5|[67]\.0)|Trident\/4\.0|Trident\/5\.0|Trident\/6\.0|Trident\/[78]\.0|Edge?\/(1[2-9]|[2-9]\d|\d{3,})\.\d+(\.\d+|)|Firefox\/([2-9]|\d{2,})\.\d+(\.\d+|)|Chrom(ium|e)\/([4-9]|\d{2,})\.\d+(\.\d+|)([\d.]+$|.*Safari\/(?![\d.]+ Edge\/[\d.]+$))|(Maci|X1{2}).+ Version\/(3\.([1-9]|\d{2,})|([4-9]|\d{2,})\.\d+)([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Opera\/9\.80.+Version\/(9\.0|9\.[56]|10\.[01]|10\.[56]|1{2}\.[01]|1{2}\.[56]|12\.[01])(\.\d+|)|Chrome.+OPR\/(1[5-9]|[2-9]\d|\d{3,})\.\d+\.\d+|[^e] (CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(3[._]([2-9]|\d{2,})|([4-9]|\d{2,})[._]\d+)([._]\d+|)|Opera Mini|Android Eclair|Android Froyo|Android Gingerbread|Android Honeycomb|Android:?[ /-](2(\.([1-9]|\d{2,})|)|([3-9]|\d{2,})(\.\d+|))(\.\d+|);(?! ARM; Trident)|(Black[Bb]er{2}y|B{2}10).+Version\/([7-9]|\d{2,})\.\d+\.\d+|Opera\/.+Opera Mobi.+Version\/(1[01]\.0|1{2}\.1|1{2}\.5|12\.[01])|Mobile Safari.+OPR\/([89]\d|\d{3,})\.\d+\.\d+|Android.+Firefox\/(14[4-9]|1[5-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Android.+Chrom(ium|e)\/(14[2-9]|1[5-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|IEMobile[ /]([1-9]\d|\d{3,})\.\d+|Android.+(UC? ?Browser|UCWEB|U3)[ /]?(15\.([5-9]|\d{2,})|(1[6-9]|[2-9]\d|\d{3,})\.\d+)\.\d+|SamsungBrowser\/([4-9]|\d{2,})\.\d+|Android.+MQ{2}Browser\/(14(\.(9|\d{2,})|)|(1[5-9]|[2-9]\d|\d{3,})(\.\d+|))(\.\d+|)|baidubrowser[\s/](13(\.(5[2-9]|[6-9]\d|\d{3,})|)|(1[4-9]|[2-9]\d|\d{3,})(\.\d+|))(\.\d+|)|K[Aa][Ii]OS\/(2\.([5-9]|\d{2,})|([3-9]|\d{2,})\.\d+)(\.\d+|)/,
});
const {is_browser_unsupported_old_version} = zrequire("browser_support");
run_test("browser present in baseline browsers is supported", () => {
window.navigator.userAgent = "Chrome/129.0.1132.57";
assert.equal(is_browser_unsupported_old_version(), false);
});
run_test("browser present in all browsers but not in baseline browsers is unsupported", () => {
window.navigator.userAgent = "Chrome/20.0.1132.57";
assert.equal(is_browser_unsupported_old_version(), true);
});
run_test("browser not present in all browsers is skipped for checking", () => {
window.navigator.userAgent = "ExerimentalBrowser/20.0.1132.57";
assert.equal(is_browser_unsupported_old_version(), false);
});
run_test("future versions of a browser are supported", () => {
window.navigator.userAgent = "Chrome/1000.0.1132.57";
assert.equal(is_browser_unsupported_old_version(), false);
});

View File

@@ -0,0 +1,729 @@
"use strict";
const assert = require("node:assert/strict");
const _ = require("lodash");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_bot, make_user} = require("./lib/example_user.cjs");
const {make_message_list} = require("./lib/message_list.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {noop, run_test} = require("./lib/test.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
mock_esm("../src/settings_data", {
user_can_access_all_other_users: () => true,
});
const timerender = mock_esm("../src/timerender");
mock_esm("../src/buddy_list", {
buddy_list: {
rerender_participants: noop,
},
});
const compose_fade_helper = zrequire("compose_fade_helper");
const activity_ui = zrequire("activity_ui");
const muted_users = zrequire("muted_users");
const peer_data = zrequire("peer_data");
const people = zrequire("people");
const presence = zrequire("presence");
const stream_data = zrequire("stream_data");
const user_status = zrequire("user_status");
const buddy_data = zrequire("buddy_data");
const message_lists = zrequire("message_lists");
const {set_current_user, set_realm} = zrequire("state_data");
const {initialize_user_settings} = zrequire("user_settings");
const {user_list_style_values} = zrequire("settings_config");
const realm = make_realm();
set_realm(realm);
const current_user = {};
set_current_user(current_user);
const user_settings = {};
initialize_user_settings({user_settings});
// The buddy_data module is mostly tested indirectly through
// activity.test.cjs, but we should feel free to add direct tests
// here.
const selma = make_user({
user_id: 1000,
full_name: "Human Selma",
email: "selma@example.com",
});
const me = make_user({
user_id: 1001,
full_name: "Human Myself",
email: "self@example.com",
});
const alice = make_user({
email: "alice@zulip.com",
user_id: 1002,
full_name: "Alice Smith",
});
const fred = make_user({
email: "fred@zulip.com",
user_id: 1003,
full_name: "Fred Flintstone",
});
const jill = make_user({
email: "jill@zulip.com",
user_id: 1004,
full_name: "Jill Hill",
});
const mark = make_user({
email: "mark@zulip.com",
user_id: 1005,
full_name: "Marky Mark",
});
const old_user = make_user({
user_id: 9999,
full_name: "Old User",
email: "old_user@example.com",
});
const imported_user = make_user({
user_id: 10000,
full_name: "Imported user",
email: "imported-user@example.com",
is_imported_stub: true,
});
const bot = make_bot({
user_id: 55555,
full_name: "Red Herring Bot",
email: "bot@example.com",
});
const bot_with_owner = make_bot({
user_id: 55556,
full_name: "Blue Herring Bot",
email: "bot_with_owner@example.com",
bot_owner_id: 1001,
bot_owner_full_name: "Human Myself",
});
function add_canned_users() {
people.add_active_user(alice, "server_events");
people.add_active_user(bot, "server_events");
people.add_active_user(bot_with_owner, "server_events");
people.add_active_user(fred, "server_events");
people.add_active_user(jill, "server_events");
people.add_active_user(mark, "server_events");
people.add_active_user(old_user, "server_events");
people.add_active_user(selma, "server_events");
people.add_active_user(me, "server_events");
}
function test(label, f) {
run_test(label, (helpers) => {
helpers.override(user_settings, "presence_enabled", true);
compose_fade_helper.clear_focused_recipient();
stream_data.clear_subscriptions();
peer_data.clear_for_testing();
user_status.initialize({user_status: {}});
presence.presence_info.clear();
people.init();
people.add_active_user(me);
people.initialize_current_user(me.user_id);
muted_users.set_muted_users([]);
message_lists.set_current(undefined);
f(helpers);
presence.clear_internal_data();
});
}
function set_presence(user_id, status) {
presence.presence_info.set(user_id, {
status,
last_active: 9999,
});
}
test("user_circle, level", ({override}) => {
add_canned_users();
set_presence(selma.user_id, "active");
assert.equal(buddy_data.get_user_circle_class(selma.user_id), "user-circle-active");
assert.equal(buddy_data.level(selma.user_id), 1);
set_presence(selma.user_id, "idle");
assert.equal(buddy_data.get_user_circle_class(selma.user_id), "user-circle-idle");
assert.equal(buddy_data.level(selma.user_id), 2);
set_presence(selma.user_id, "offline");
assert.equal(buddy_data.get_user_circle_class(selma.user_id), "user-circle-offline");
assert.equal(buddy_data.level(selma.user_id), 3);
set_presence(me.user_id, "active");
assert.equal(buddy_data.get_user_circle_class(me.user_id), "user-circle-active");
assert.equal(buddy_data.level(me.user_id), 0);
override(user_settings, "presence_enabled", false);
assert.equal(buddy_data.get_user_circle_class(me.user_id), "user-circle-offline");
assert.equal(buddy_data.level(me.user_id), 0);
override(user_settings, "presence_enabled", true);
assert.equal(buddy_data.get_user_circle_class(me.user_id), "user-circle-active");
assert.equal(buddy_data.level(me.user_id), 0);
set_presence(fred.user_id, "idle");
assert.equal(buddy_data.get_user_circle_class(fred.user_id), "user-circle-idle");
assert.equal(buddy_data.level(fred.user_id), 2);
set_presence(fred.user_id, undefined);
assert.equal(buddy_data.get_user_circle_class(fred.user_id), "user-circle-offline");
assert.equal(buddy_data.level(fred.user_id), 3);
set_presence(fred.user_id, undefined);
assert.equal(buddy_data.get_user_circle_class(fred.user_id, true), "user-circle-deactivated");
assert.equal(buddy_data.level(fred.user_id), 3);
});
test("title_data", ({override}) => {
page_params.presence_history_limit_days_for_web_app = 365;
add_canned_users();
// Groups
let is_group = true;
const user_ids_string = "9999,1000";
let expected_group_data = {
first_line: "Human Selma and Old User",
second_line: "",
third_line: "",
};
assert.deepEqual(
buddy_data.get_title_data(user_ids_string, is_group, true),
expected_group_data,
);
is_group = "";
// Bots with owners.
expected_group_data = {
first_line: "Blue Herring Bot",
second_line: "translated: Owner: Human Myself",
third_line: "",
is_deactivated: false,
};
assert.deepEqual(
buddy_data.get_title_data(bot_with_owner.user_id, is_group, true),
expected_group_data,
);
// Bots without owners.
expected_group_data = {
first_line: "Red Herring Bot",
second_line: "",
third_line: "",
};
assert.deepEqual(buddy_data.get_title_data(bot.user_id, is_group, true), expected_group_data);
// Individual users.
user_status.set_status_text({
user_id: me.user_id,
status_text: "out to lunch",
});
override(user_settings, "user_list_style", user_list_style_values.with_status.code);
let expected_data = {
first_line: "Human Myself",
second_line: "",
third_line: "translated: Active now",
show_you: true,
};
override(current_user, "user_id", me.user_id);
assert.deepEqual(buddy_data.get_title_data(me.user_id, is_group, false), expected_data);
expected_data = {
first_line: "Human Myself",
second_line: "out to lunch",
third_line: "translated: Active now",
show_you: true,
};
override(current_user, "user_id", me.user_id);
assert.deepEqual(buddy_data.get_title_data(me.user_id, is_group, true), expected_data);
expected_data = {
first_line: "Old User",
second_line: "translated: Not active in the last year",
third_line: "",
show_you: false,
};
assert.deepEqual(buddy_data.get_title_data(old_user.user_id, is_group, true), expected_data);
// Deactivated users.
people.deactivate(selma);
expected_data = {
first_line: "Human Selma",
second_line: "translated: This user has been deactivated.",
third_line: "",
show_you: false,
is_deactivated: true,
};
assert.deepEqual(buddy_data.get_title_data(selma.user_id, is_group, true), expected_data);
// Deactivated bots.
people.deactivate(bot_with_owner);
expected_group_data = {
first_line: "Blue Herring Bot",
second_line: "translated: Owner: Human Myself",
third_line: "translated: This bot has been deactivated.",
is_deactivated: true,
};
assert.deepEqual(
buddy_data.get_title_data(bot_with_owner.user_id, is_group, true),
expected_group_data,
);
});
test("filters deactivated users", () => {
add_canned_users();
set_presence(selma.user_id, "active");
set_presence(me.user_id, "active");
let user_ids = buddy_data.get_filtered_and_sorted_user_ids("selm");
assert.deepEqual(user_ids, [selma.user_id]);
assert.ok(buddy_data.matches_filter("selm", selma.user_id));
// Deactivated users are excluded.
people.deactivate(selma);
user_ids = buddy_data.get_filtered_and_sorted_user_ids("selm");
assert.deepEqual(user_ids, []);
user_ids = buddy_data.get_filtered_and_sorted_user_ids();
assert.equal(user_ids.includes(selma.user_id), false);
assert.ok(!buddy_data.matches_filter("selm", selma.user_id));
});
test("deleted users excluded from search", () => {
const deleted_selma = {...selma, is_deleted: true};
people._add_user(deleted_selma);
let user_ids = buddy_data.get_filtered_and_sorted_user_ids();
assert.equal(user_ids.includes(selma.user_id), false);
user_ids = buddy_data.get_filtered_and_sorted_user_ids("selm");
assert.deepEqual(user_ids, []);
assert.ok(!buddy_data.matches_filter("selm", selma.user_id));
});
test("muted users excluded from search", () => {
people.add_active_user(selma);
muted_users.add_muted_user(selma.user_id);
let user_ids = buddy_data.get_filtered_and_sorted_user_ids();
assert.equal(user_ids.includes(selma.user_id), false);
user_ids = buddy_data.get_filtered_and_sorted_user_ids("selm");
assert.deepEqual(user_ids, []);
assert.ok(!buddy_data.matches_filter("selm", selma.user_id));
muted_users.remove_muted_user(selma.user_id);
user_ids = buddy_data.get_filtered_and_sorted_user_ids("selm");
assert.deepEqual(user_ids, [selma.user_id]);
assert.ok(buddy_data.matches_filter("selm", selma.user_id));
});
test("bulk_data_hacks", ({override_rewire}) => {
// sanity check
assert.equal(mark.user_id, 1005);
const start_id = mark.user_id + 1;
for (const i of _.range(start_id, 2000)) {
const person = {
user_id: i,
full_name: `Human ${i}`,
email: `person${i}@example.com`,
};
people.add_active_user(person);
}
add_canned_users();
// Make 400 of the users active
set_presence(selma.user_id, "active");
set_presence(me.user_id, "active");
for (const user_id of _.range(1000, 1400)) {
set_presence(user_id, "active");
}
// And then 300 not active
for (const user_id of _.range(1400, 1700)) {
set_presence(user_id, "offline");
}
let user_ids;
// Even though we have 1000 users, we only get the 400 active
// users. This is a consequence of buddy_data.maybe_shrink_list.
user_ids = buddy_data.get_filtered_and_sorted_user_ids();
assert.equal(user_ids.length, 400);
user_ids = buddy_data.get_filtered_and_sorted_user_ids("");
assert.equal(user_ids.length, 400);
// We don't match on "so", because it's not at the start of a
// word in the name/email.
user_ids = buddy_data.get_filtered_and_sorted_user_ids("so");
assert.equal(user_ids.length, 0);
// We match on "h" for the first name, and the result limit
// is relaxed for searches.
user_ids = buddy_data.get_filtered_and_sorted_user_ids("h");
assert.equal(user_ids.length, 997);
// We match on "p" for the email.
user_ids = buddy_data.get_filtered_and_sorted_user_ids("p");
assert.equal(user_ids.length, 994);
// Make our shrink limit higher, and go back to an empty search.
// We won't get all 1000 users, just the present ones.
override_rewire(buddy_data, "max_size_before_shrinking", 50000);
user_ids = buddy_data.get_filtered_and_sorted_user_ids("");
assert.equal(user_ids.length, 700);
});
test("always show me", () => {
assert.deepEqual(buddy_data.get_filtered_and_sorted_user_ids(""), [me.user_id]);
// try to make us show twice
presence.presence_info.set(me.user_id, {status: "active"});
assert.deepEqual(buddy_data.get_filtered_and_sorted_user_ids(""), [me.user_id]);
});
test("always show pm users", () => {
people.add_active_user(selma, "server_events");
message_lists.set_current(make_message_list([{operator: "dm", operand: [selma.user_id]}]));
assert.deepEqual(buddy_data.get_filtered_and_sorted_user_ids(""), [me.user_id, selma.user_id]);
});
test("show offline channel subscribers for small channels", ({override_rewire}) => {
// Add a bunch of users to hit max_size_before_shrinking, which will hide some
// offline users.
for (const i of _.range(2000, 2700)) {
const person = {
user_id: i,
full_name: `Human ${i}`,
email: `person${i}@example.com`,
};
people.add_active_user(person);
}
for (const user_id of _.range(2000, 2700)) {
set_presence(user_id, "offline");
}
people.add_active_user(alice);
set_presence(alice.user_id, "active");
people.add_active_user(fred);
set_presence(fred.user_id, "offline");
people.add_active_user(selma);
set_presence(selma.user_id, "offline");
people.add_active_user(jill);
set_presence(jill.user_id, "offline");
const stream_id = 1001;
const sub = make_stream({name: "Rome", stream_id});
stream_data.add_sub_for_tests(sub);
peer_data.set_subscribers(stream_id, [
selma.user_id,
alice.user_id,
fred.user_id,
jill.user_id,
me.user_id,
]);
const filter_terms = [
{operator: "channel", operand: String(sub.stream_id)},
{operator: "topic", operand: "Foo"},
];
message_lists.set_current(make_message_list(filter_terms));
assert.deepEqual(buddy_data.get_filtered_and_sorted_user_ids(""), [
me.user_id,
alice.user_id,
fred.user_id,
selma.user_id,
jill.user_id,
]);
// Make the max channel size lower, so that we hide the offline users
override_rewire(buddy_data, "max_channel_size_to_show_all_subscribers", 2);
assert.deepEqual(buddy_data.get_filtered_and_sorted_user_ids(""), [me.user_id, alice.user_id]);
});
test("get_conversation_participants", () => {
people.add_active_user(selma);
const rome_sub = make_stream({name: "Rome", stream_id: 1001});
stream_data.add_sub_for_tests(rome_sub);
peer_data.set_subscribers(rome_sub.stream_id, [selma.user_id, me.user_id]);
const filter_terms = [
{operator: "channel", operand: String(rome_sub.stream_id)},
{operator: "topic", operand: "Foo"},
];
message_lists.set_current(
make_message_list(filter_terms, {
visible_participants: [selma.user_id],
}),
);
activity_ui.rerender_user_sidebar_participants();
assert.deepEqual(
buddy_data.get_conversation_participants_callback()(),
new Set([selma.user_id]),
);
});
test("level", ({override}) => {
override(realm, "server_presence_offline_threshold_seconds", 200);
add_canned_users();
assert.equal(buddy_data.level(me.user_id), 0);
assert.equal(buddy_data.level(selma.user_id), 3);
const server_time = 9999;
const info = {
active_timestamp: 9999,
idle_timestamp: 9999,
};
presence.update_info_from_event(me.user_id, info, server_time);
presence.update_info_from_event(selma.user_id, info, server_time);
assert.equal(buddy_data.level(me.user_id), 0);
assert.equal(buddy_data.level(selma.user_id), 1);
override(user_settings, "presence_enabled", false);
set_presence(selma.user_id, "offline");
// Selma gets demoted to level 3, but "me"
// stays in level 0.
assert.equal(buddy_data.level(me.user_id), 0);
assert.equal(buddy_data.level(selma.user_id), 3);
});
test("compare_function", () => {
const first_user_shown_higher = -1;
const second_user_shown_higher = 1;
const stream_id = 1001;
const sub = make_stream({name: "Rome", stream_id});
stream_data.add_sub_for_tests(sub);
people.add_active_user(alice);
people.add_active_user(fred);
// Alice is higher because of alphabetical sorting.
peer_data.set_subscribers(stream_id, []);
assert.equal(
second_user_shown_higher,
buddy_data.compare_function(fred.user_id, alice.user_id, stream_id, new Set(), new Set()),
);
// Fred is higher because they're in the narrow and Alice isn't.
peer_data.set_subscribers(stream_id, [fred.user_id]);
assert.equal(
first_user_shown_higher,
buddy_data.compare_function(fred.user_id, alice.user_id, stream_id, new Set(), new Set()),
);
assert.equal(
second_user_shown_higher,
buddy_data.compare_function(alice.user_id, fred.user_id, stream_id, new Set(), new Set()),
);
// Fred is higher because they're in the DM conversation and Alice isn't.
assert.equal(
first_user_shown_higher,
buddy_data.compare_function(
fred.user_id,
alice.user_id,
undefined,
new Set([fred.user_id]),
new Set(),
),
);
// Fred is higher because they're in the conversation and Alice isn't.
assert.equal(
first_user_shown_higher,
buddy_data.compare_function(
fred.user_id,
alice.user_id,
undefined,
new Set(),
new Set([fred.user_id]),
),
);
assert.equal(
second_user_shown_higher,
buddy_data.compare_function(
alice.user_id,
fred.user_id,
undefined,
new Set(),
new Set([fred.user_id]),
),
);
// Alice is higher because of alphabetical sorting.
assert.equal(
second_user_shown_higher,
buddy_data.compare_function(fred.user_id, alice.user_id, undefined, new Set(), new Set()),
);
// The user is part of a DM conversation, though that's not explicitly in the DM list.
assert.equal(
first_user_shown_higher,
buddy_data.compare_function(
me.user_id,
alice.user_id,
undefined,
new Set([fred.user_id]),
new Set(),
),
);
});
test("user_last_seen_time_status", ({override}) => {
page_params.presence_history_limit_days_for_web_app = 365;
people.add_active_user(old_user);
set_presence(selma.user_id, "active");
set_presence(me.user_id, "active");
assert.equal(buddy_data.user_last_seen_time_status(selma.user_id), "translated: Active now");
assert.equal(
buddy_data.user_last_seen_time_status(old_user.user_id),
"translated: Not active in the last year",
);
presence.presence_info.set(old_user.user_id, {last_active: 1526137743});
override(timerender, "last_seen_status_from_date", (date) => {
assert.deepEqual(date, new Date(1526137743000));
return "translated: Active May 12";
});
assert.equal(
buddy_data.user_last_seen_time_status(old_user.user_id),
"translated: Active May 12",
);
set_presence(selma.user_id, "idle");
assert.equal(buddy_data.user_last_seen_time_status(selma.user_id), "translated: Idle");
presence.presence_info.set(old_user.user_id, {last_active: undefined});
const missing_callback = (user_id) => {
assert.equal(user_id, old_user.user_id);
};
assert.equal(buddy_data.user_last_seen_time_status(old_user.user_id, missing_callback), "");
people.add_active_user(imported_user);
assert.equal(
buddy_data.user_last_seen_time_status(imported_user.user_id),
"translated: Imported account not activated",
);
});
test("get_items_for_users", ({override}) => {
add_canned_users();
set_presence(alice.user_id, "offline");
override(user_settings, "emojiset", "google");
override(user_settings, "user_list_style", 2);
const status_emoji_info = {
emoji_alt_code: false,
emoji_name: "car",
emoji_code: "1f697",
reaction_type: "unicode_emoji",
};
const status_emoji_info_event = {
id: 1,
type: "user_status",
status_text: "",
...status_emoji_info,
};
const user_ids = [me.user_id, alice.user_id, fred.user_id];
for (const user_id of user_ids) {
user_status.set_status_emoji({user_id, ...status_emoji_info_event});
}
const user_list_style = {
COMPACT: false,
WITH_STATUS: true,
WITH_AVATAR: false,
};
assert.deepEqual(buddy_data.get_items_for_users(user_ids), [
{
href: "#narrow/dm/1001-Human-Myself",
is_current_user: true,
name: "Human Myself",
num_unread: 0,
profile_picture: "/avatar/1001",
status_emoji_info,
status_text: undefined,
has_status_text: false,
user_circle_class: "user-circle-active",
user_id: 1001,
user_list_style,
should_add_guest_user_indicator: false,
},
{
href: "#narrow/dm/1002-Alice-Smith",
is_current_user: false,
name: "Alice Smith",
num_unread: 0,
profile_picture: "/avatar/1002",
status_emoji_info,
status_text: undefined,
has_status_text: false,
user_circle_class: "user-circle-offline",
user_id: 1002,
user_list_style,
should_add_guest_user_indicator: false,
},
{
href: "#narrow/dm/1003-Fred-Flintstone",
is_current_user: false,
name: "Fred Flintstone",
num_unread: 0,
profile_picture: "/avatar/1003",
status_emoji_info,
status_text: undefined,
has_status_text: false,
user_circle_class: "user-circle-offline",
user_id: 1003,
user_list_style,
should_add_guest_user_indicator: false,
},
]);
});
test("unknown user id in presence", () => {
// This test is to make sure that we don't generate errors
// when we have a user_id in presence that we don't have
// information about.
// Such scenarios can happen if we receive presence info involving
// a new user before the user creation event reaches us.
presence.presence_info.set(999, {status: "active"});
const user_ids = buddy_data.get_filtered_and_sorted_user_ids();
// This user id should not be present in the filtered list,
// it's meant to be ignored until we know about the user.
assert.equal(user_ids.includes(999), false);
});

View File

@@ -0,0 +1,376 @@
"use strict";
const assert = require("node:assert/strict");
const _ = require("lodash");
const {
clear_buddy_list,
override_user_matches_narrow_using_loaded_data,
buddy_list_add_user_matching_view,
buddy_list_add_other_user,
stub_buddy_list_elements,
} = require("./lib/buddy_list.cjs");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {$} = require("./lib/zjquery.cjs");
const padded_widget = mock_esm("../src/padded_widget");
const message_viewport = mock_esm("../src/message_viewport");
const background_task = mock_esm("../src/background_task");
mock_esm("../src/sidebar_header_sticky_shadow", {initialize() {}});
const buddy_data = zrequire("buddy_data");
const {BuddyList} = zrequire("buddy_list");
const people = zrequire("people");
const {set_realm} = zrequire("state_data");
const {initialize_user_settings} = zrequire("user_settings");
set_realm(make_realm());
initialize_user_settings({user_settings: {}});
function init_simulated_scrolling() {
const $wrapper = $.create("#buddy_list_wrapper");
$wrapper[0].scrollHeight = 0;
$wrapper[0].scrollTop = 0;
$("#buddy_list_wrapper_padding").set_height(0);
return $wrapper[0];
}
const alice = make_user({
email: "alice@zulip.com",
user_id: 10,
full_name: "Alice Smith",
});
people.add_active_user(alice);
const bob = make_user({
email: "bob@zulip.com",
user_id: 15,
full_name: "Bob Smith",
});
people.add_active_user(bob);
const chris = make_user({
email: "chris@zulip.com",
user_id: 20,
full_name: "Chris Smith",
});
people.add_active_user(chris);
const $alice_li = $.create("alice-stub");
const $bob_li = $.create("bob-stub");
run_test("basics", ({override}) => {
const buddy_list = new BuddyList();
init_simulated_scrolling();
override(buddy_list, "items_to_html", () => "<html-stub>");
override(message_viewport, "height", () => 550);
override(padded_widget, "update_padding", noop);
stub_buddy_list_elements();
override(background_task, "run_async_function_without_await", noop);
buddy_list.populate({
all_user_ids: [alice.user_id],
});
const $alice_li = "alice-stub";
override(buddy_list, "get_li_from_user_id", (opts) => {
const user_id = opts.user_id;
assert.equal(user_id, alice.user_id);
return $alice_li;
});
const $li = buddy_list.find_li({
key: alice.user_id,
});
assert.equal($li, $alice_li);
});
run_test("split list", ({override, override_rewire}) => {
const buddy_list = new BuddyList();
init_simulated_scrolling();
stub_buddy_list_elements();
override_rewire(
buddy_data,
"user_matches_narrow_using_loaded_data",
override_user_matches_narrow_using_loaded_data,
);
override(buddy_list, "items_to_html", (opts) => {
assert.ok(opts.items.length > 0);
return "<html-stub>";
});
override(message_viewport, "height", () => 550);
override(padded_widget, "update_padding", noop);
let appended_to_users_matching_view = false;
$("#buddy-list-users-matching-view")[0].append = (element) => {
if (element.innerHTML === "<html-stub>") {
appended_to_users_matching_view = true;
}
};
let appended_to_other_users = false;
$("#buddy-list-other-users")[0].append = (element) => {
if (element.innerHTML === "<html-stub>") {
appended_to_other_users = true;
}
};
// one user matching the view
buddy_list_add_user_matching_view(alice.user_id, $alice_li);
override(background_task, "run_async_function_without_await", noop);
buddy_list.populate({
all_user_ids: [alice.user_id],
});
assert.ok(appended_to_users_matching_view);
assert.ok(!appended_to_other_users);
appended_to_users_matching_view = false;
// one other user
clear_buddy_list(buddy_list);
buddy_list_add_other_user(alice.user_id, $alice_li);
buddy_list.populate({
all_user_ids: [alice.user_id],
});
assert.ok(!appended_to_users_matching_view);
assert.ok(appended_to_other_users);
appended_to_other_users = false;
// a user matching the view, and another user
clear_buddy_list(buddy_list);
buddy_list_add_user_matching_view(alice.user_id, $alice_li);
buddy_list_add_other_user(bob.user_id, $bob_li);
buddy_list.populate({
all_user_ids: [alice.user_id, bob.user_id],
});
assert.ok(appended_to_users_matching_view);
assert.ok(appended_to_other_users);
});
run_test("find_li", ({override}) => {
const buddy_list = new BuddyList();
override(buddy_list, "fill_screen_with_content", noop);
stub_buddy_list_elements();
override(background_task, "run_async_function_without_await", noop);
clear_buddy_list(buddy_list);
buddy_list_add_user_matching_view(alice.user_id, $alice_li);
buddy_list_add_other_user(bob.user_id, $bob_li);
let $li = buddy_list.find_li({
key: alice.user_id,
});
assert.equal($li[0], $alice_li[0]);
$li = buddy_list.find_li({
key: bob.user_id,
});
assert.equal($li[0], $bob_li[0]);
});
run_test("fill_screen_with_content early break on big list", ({override}) => {
stub_buddy_list_elements();
const buddy_list = new BuddyList();
const elem = init_simulated_scrolling();
stub_buddy_list_elements();
let chunks_inserted = 0;
override(buddy_list, "render_more", () => {
elem.scrollHeight += 100;
chunks_inserted += 1;
});
override(message_viewport, "height", () => 550);
// We will have more than enough users, but still
// only do 6 chunks of data (20 users per chunk)
// because of exiting early from fill_screen_with_content
// because of not scrolling enough to fetch more users.
const num_users = 300;
const user_ids = [];
_.times(num_users, (i) => {
const person = make_user({
email: "foo" + i + "@zulip.com",
user_id: 100 + i,
full_name: "Somebody " + i,
});
people.add_active_user(person);
user_ids.push(person.user_id);
});
override(background_task, "run_async_function_without_await", noop);
buddy_list.populate({
all_user_ids: user_ids,
});
// Only 6 chunks, even though that's 120 users instead of the full 300.
assert.equal(chunks_inserted, 6);
});
run_test("big_list", ({override, override_rewire}) => {
const buddy_list = new BuddyList();
init_simulated_scrolling();
stub_buddy_list_elements();
override(padded_widget, "update_padding", noop);
override(message_viewport, "height", () => 550);
override_rewire(
buddy_data,
"user_matches_narrow_using_loaded_data",
override_user_matches_narrow_using_loaded_data,
);
let items_to_html_call_count = 0;
override(buddy_list, "items_to_html", () => {
items_to_html_call_count += 1;
return "<html-stub>";
});
const num_users = 300;
const user_ids = [];
// This isn't a great way of testing this, but this is here for
// the sake of code coverage. Essentially, for a very long list,
// these buddy list sections can collect empty messages in the middle
// of populating (i.e. once a chunk is rendered) which later might need
// to be removed to add users from future chunks.
//
// For example: chunk1 populates only users in the list of users matching,
// the view and the empty list says "None", but chunk2 adds users to the
// other list so the "None" message should be removed.
//
// Here we're just saying both lists are rendered as empty from start,
// which doesn't actually happen, since I don't know how to properly
// get it set in the middle of buddy_list.populate().
$.reset_selector("#buddy-list-users-matching-view .empty-list-message");
$("#buddy-list-users-matching-view .empty-list-message")[0].remove = noop;
$.reset_selector("#buddy-list-other-users .empty-list-message");
$("#buddy-list-users-matching-view .empty-list-message")[0].remove = noop;
_.times(num_users, (i) => {
const person = make_user({
email: "foo" + i + "@zulip.com",
user_id: 100 + i,
full_name: "Somebody " + i,
});
people.add_active_user(person);
if (i < 100 || i % 2 === 0) {
buddy_list_add_user_matching_view(person.user_id, $.create("stub" + i));
} else {
buddy_list_add_other_user(person.user_id, $.create("stub" + i));
}
user_ids.push(person.user_id);
});
override(background_task, "run_async_function_without_await", noop);
buddy_list.populate({
all_user_ids: user_ids,
});
// Chunks are default size 20, so there should be 300/20 = 15 chunks
// For the first 100/20 = 5 chunks, we only call to add a user matching ivew.
// For the last 10 chunks, there are two calls: one for users_matching_view
// and one for other_users.
assert.equal(items_to_html_call_count, 5 + 10 * 2);
});
run_test("force_render", ({override}) => {
const buddy_list = new BuddyList();
buddy_list.render_count = 50;
let num_rendered = 0;
override(buddy_list, "render_more", (opts) => {
num_rendered += opts.chunk_size;
});
buddy_list.force_render({
pos: 60,
});
assert.equal(num_rendered, 60 - 50 + 3);
// Force a contrived error case for line coverage.
blueslip.expect("error", "cannot show user id at this position");
buddy_list.force_render({
pos: 10,
});
});
run_test("find_li w/force_render", ({override}) => {
const buddy_list = new BuddyList();
// If we call find_li w/force_render set, and the
// user_id is not already rendered in DOM, then the
// widget will force-render it.
const user_id = "999";
const $stub_li = "stub-li";
override(buddy_list, "get_li_from_user_id", (opts) => {
assert.equal(opts.user_id, user_id);
return $stub_li;
});
buddy_list.all_user_ids = ["foo", "bar", user_id, "baz"];
let shown;
override(buddy_list, "force_render", (opts) => {
assert.equal(opts.pos, 2);
shown = true;
});
const $hidden_li = buddy_list.find_li({
key: user_id,
});
assert.equal($hidden_li, $stub_li);
assert.ok(!shown);
const $li = buddy_list.find_li({
key: user_id,
force_render: true,
});
assert.equal($li, $stub_li);
assert.ok(shown);
});
run_test("find_li w/bad key", ({override}) => {
const buddy_list = new BuddyList();
override(buddy_list, "get_li_from_user_id", () => "stub-li");
const $undefined_li = buddy_list.find_li({
key: "not-there",
force_render: true,
});
assert.deepEqual($undefined_li, undefined);
});
run_test("scrolling", ({override}) => {
const buddy_list = new BuddyList();
let tried_to_fill;
override(buddy_list, "fill_screen_with_content", () => {
tried_to_fill = true;
});
stub_buddy_list_elements();
init_simulated_scrolling();
stub_buddy_list_elements();
override(background_task, "run_async_function_without_await", noop);
clear_buddy_list(buddy_list);
assert.ok(tried_to_fill);
tried_to_fill = false;
buddy_list.start_scroll_handler();
$(buddy_list.scroll_container_selector).trigger("scroll");
assert.ok(tried_to_fill);
});

399
web/tests/channel.test.cjs Normal file
View File

@@ -0,0 +1,399 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_jquery, mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
const xhr_401 = {
status: 401,
responseJSON: {msg: "Use cannot access XYZ"},
};
let login_to_access_shown = false;
mock_esm("../src/spectators", {
login_to_access() {
login_to_access_shown = true;
},
});
set_global("window", {
location: {
replace() {},
href: "http://example.com",
},
});
const reload_state = zrequire("reload_state");
const channel = zrequire("channel");
const default_stub_xhr = {"default-stub-xhr": 0};
const $ = mock_jquery({});
function test_with_mock_ajax(test_params) {
const {xhr = default_stub_xhr, run_code, check_ajax_options} = test_params;
let ajax_called;
let ajax_options;
$.ajax = (options) => {
$.ajax = undefined;
ajax_called = true;
ajax_options = options;
options.simulate_success = (data, text_status) => {
options.success(data, text_status, xhr);
};
options.simulate_error = () => {
options.error(xhr);
};
return xhr;
};
run_code();
assert.ok(ajax_called);
check_ajax_options(ajax_options);
}
function test(label, f) {
run_test(label, ({override}) => {
reload_state.clear_for_testing();
f({override});
});
}
test("post", () => {
test_with_mock_ajax({
run_code() {
channel.post({url: "/json/endpoint"});
},
check_ajax_options(options) {
assert.equal(options.type, "POST");
assert.equal(options.dataType, "json");
// Just make sure these don't explode.
options.simulate_success();
options.simulate_error();
},
});
});
test("patch", () => {
test_with_mock_ajax({
run_code() {
channel.patch({url: "/json/endpoint"});
},
check_ajax_options(options) {
assert.equal(options.type, "PATCH");
assert.equal(options.dataType, "json");
// Just make sure these don't explode.
options.simulate_success();
options.simulate_error();
},
});
});
test("put", () => {
test_with_mock_ajax({
run_code() {
channel.put({url: "/json/endpoint"});
},
check_ajax_options(options) {
assert.equal(options.type, "PUT");
assert.equal(options.dataType, "json");
// Just make sure these don't explode.
options.simulate_success();
options.simulate_error();
},
});
});
test("delete", () => {
test_with_mock_ajax({
run_code() {
channel.del({url: "/json/endpoint"});
},
check_ajax_options(options) {
assert.equal(options.type, "DELETE");
assert.equal(options.dataType, "json");
// Just make sure these don't explode.
options.simulate_success();
options.simulate_error();
},
});
});
test("get", () => {
test_with_mock_ajax({
run_code() {
channel.get({url: "/json/endpoint"});
},
check_ajax_options(options) {
assert.equal(options.type, "GET");
assert.equal(options.dataType, "json");
// Just make sure these don't explode.
options.simulate_success();
options.simulate_error();
},
});
});
test("normal_post", () => {
const data = {
s: "some_string",
num: 7,
lst: [1, 2, 4, 8],
};
let orig_success_called;
let orig_error_called;
const stub_xhr = {"stub-xhr-normal-post": 0};
test_with_mock_ajax({
xhr: stub_xhr,
run_code() {
channel.post({
data,
url: "/json/endpoint",
success(data, text_status, xhr) {
orig_success_called = true;
assert.equal(data, "response data");
assert.equal(text_status, "success");
assert.equal(xhr, stub_xhr);
},
error() {
orig_error_called = true;
},
});
},
check_ajax_options(options) {
assert.equal(options.type, "POST");
assert.equal(options.dataType, "json");
assert.deepEqual(options.data, data);
assert.equal(options.url, "/json/endpoint");
options.simulate_success("response data", "success");
assert.ok(orig_success_called);
options.simulate_error();
assert.ok(orig_error_called);
},
});
});
test("authentication_error_401_is_spectator", () => {
test_with_mock_ajax({
xhr: xhr_401,
run_code() {
channel.post({url: "/json/endpoint"});
},
// is_spectator = true
check_ajax_options(options) {
page_params.page_type = "home";
page_params.is_spectator = true;
options.simulate_error();
assert.ok(login_to_access_shown);
login_to_access_shown = false;
},
});
});
test("authentication_error_401_password_change_in_progress", () => {
test_with_mock_ajax({
xhr: xhr_401,
run_code() {
channel.post({url: "/json/endpoint"});
},
// is_spectator = true
// password_change_in_progress = true
check_ajax_options(options) {
page_params.page_type = "home";
page_params.is_spectator = true;
channel.set_password_change_in_progress(true);
options.simulate_error();
assert.ok(!login_to_access_shown);
channel.set_password_change_in_progress(false);
page_params.is_spectator = false;
login_to_access_shown = false;
},
});
});
test("authentication_error_401_not_spectator", () => {
test_with_mock_ajax({
xhr: xhr_401,
run_code() {
channel.post({url: "/json/endpoint"});
},
// is_spectator = false
check_ajax_options(options) {
page_params.page_type = "home";
page_params.is_spectator = false;
options.simulate_error();
assert.ok(!login_to_access_shown);
login_to_access_shown = false;
},
});
});
test("reload_on_403_error", () => {
test_with_mock_ajax({
xhr: {
status: 403,
responseJSON: {msg: "CSRF Fehler: etwas", code: "CSRF_FAILED"},
},
run_code() {
channel.post({url: "/json/endpoint"});
},
check_ajax_options(options) {
let handler_called = false;
reload_state.set_csrf_failed_handler(() => {
handler_called = true;
});
options.simulate_error();
assert.ok(handler_called);
},
});
});
test("unexpected_403_response", () => {
test_with_mock_ajax({
xhr: {
status: 403,
responseJSON: undefined,
responseText: "unexpected",
},
run_code() {
channel.post({url: "/json/endpoint"});
},
check_ajax_options(options) {
blueslip.expect("error", "Unexpected 403 response from server");
options.simulate_error();
},
});
});
test("xhr_error_message", () => {
let xhr = {
status: "200",
responseJSON: undefined,
responseText: "does not matter",
};
let msg = "data added";
assert.equal(channel.xhr_error_message(msg, xhr), "data added");
xhr = {
status: "404",
responseJSON: {msg: "file not found"},
};
msg = "some message";
assert.equal(channel.xhr_error_message(msg, xhr), "some message: file not found");
msg = "";
assert.equal(channel.xhr_error_message(msg, xhr), "file not found");
});
test("while_reloading", () => {
reload_state.set_state_to_in_progress();
assert.equal(channel.get({ignore_reload: false}), undefined);
test_with_mock_ajax({
run_code() {
channel.del({
url: "/json/endpoint",
ignore_reload: true,
/* istanbul ignore next */
success() {
throw new Error("unexpected success");
},
/* istanbul ignore next */
error() {
throw new Error("unexpected error");
},
});
},
check_ajax_options(options) {
blueslip.expect("log", "Ignoring DELETE /json/endpoint response while reloading");
options.simulate_success();
blueslip.expect("log", "Ignoring DELETE /json/endpoint error response while reloading");
options.simulate_error();
},
});
});
test("error in callback", () => {
let success_called = false;
let error_called = false;
let raised_error = false;
test_with_mock_ajax({
run_code() {
channel.get({
url: "/json/endpoint",
success() {
success_called = true;
throw new Error("success");
},
error() {
error_called = true;
throw new Error("failure");
},
});
},
check_ajax_options(options) {
try {
options.simulate_success();
} catch (error) {
assert.equal(error.message, "success");
raised_error = true;
}
assert.ok(success_called);
assert.ok(raised_error);
assert.ok(!error_called);
success_called = false;
raised_error = false;
try {
options.simulate_error();
} catch (error) {
assert.equal(error.message, "failure");
raised_error = true;
}
assert.ok(!success_called);
assert.ok(raised_error);
assert.ok(error_called);
},
});
});

View File

@@ -0,0 +1,182 @@
"use strict";
const assert = require("node:assert/strict");
const {make_stream} = require("./lib/example_stream.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const channel_folders = zrequire("channel_folders");
const stream_data = zrequire("stream_data");
run_test("basics", () => {
const params = {};
const frontend_folder = {
name: "Frontend",
description: "Channels for frontend discussions",
rendered_description: "<p>Channels for frontend discussions</p>",
creator_id: null,
date_created: 1596710000,
id: 1,
is_archived: false,
order: 0,
};
const backend_folder = {
name: "Backend",
description: "Channels for backend discussions",
rendered_description: "<p>Channels for backend discussions</p>",
creator_id: null,
date_created: 1596720000,
id: 2,
is_archived: false,
order: 1,
};
params.channel_folders = [frontend_folder, backend_folder];
channel_folders.initialize(params);
assert.deepEqual(channel_folders.get_channel_folders(), [frontend_folder, backend_folder]);
assert.deepEqual(
channel_folders.get_active_folder_ids(),
new Set([frontend_folder.id, backend_folder.id]),
);
const devops_folder = {
name: "Devops",
description: "",
rendered_description: "",
creator_id: 1,
date_created: 1596810000,
id: 3,
is_archived: false,
order: 2,
};
channel_folders.add(devops_folder);
assert.deepEqual(channel_folders.get_channel_folders(), [
frontend_folder,
backend_folder,
devops_folder,
]);
devops_folder.is_archived = true;
assert.deepEqual(channel_folders.get_channel_folders(), [frontend_folder, backend_folder]);
assert.deepEqual(channel_folders.get_channel_folders(true), [
frontend_folder,
backend_folder,
devops_folder,
]);
assert.deepEqual(
channel_folders.get_all_folder_ids(),
new Set([frontend_folder.id, backend_folder.id, devops_folder.id]),
);
assert.ok(channel_folders.is_valid_folder_id(frontend_folder.id));
assert.ok(!channel_folders.is_valid_folder_id(999));
assert.equal(channel_folders.get_channel_folder_by_id(frontend_folder.id), frontend_folder);
const new_order = [backend_folder.id, devops_folder.id, frontend_folder.id];
channel_folders.reorder(new_order);
assert.equal(backend_folder.order, 0);
assert.equal(devops_folder.order, 1);
assert.equal(frontend_folder.order, 2);
const stream_1 = make_stream({
stream_id: 1,
name: "Stream 1",
folder_id: null,
});
const stream_2 = make_stream({
stream_id: 2,
name: "Stream 2",
folder_id: frontend_folder.id,
});
const stream_3 = make_stream({
stream_id: 3,
name: "Stream 3",
folder_id: devops_folder.id,
});
const stream_4 = make_stream({
stream_id: 4,
name: "Stream 4",
folder_id: frontend_folder.id,
});
const stream_5 = make_stream({
stream_id: 5,
name: "channel 5",
folder_id: frontend_folder.id,
});
stream_data.add_sub_for_tests(stream_1);
assert.deepEqual(channel_folders.user_has_folders(), false);
stream_data.add_sub_for_tests(stream_2);
stream_data.add_sub_for_tests(stream_3);
stream_data.add_sub_for_tests(stream_4);
stream_data.add_sub_for_tests(stream_5);
assert.deepEqual(channel_folders.get_stream_ids_in_folder(frontend_folder.id), [
stream_2.stream_id,
stream_4.stream_id,
stream_5.stream_id,
]);
assert.deepEqual(channel_folders.get_stream_ids_in_folder(devops_folder.id), [
stream_3.stream_id,
]);
assert.deepEqual(channel_folders.get_stream_ids_in_folder(backend_folder.id), []);
assert.deepEqual(channel_folders.get_sorted_streams_in_folder(frontend_folder.id), [
stream_5,
stream_2,
stream_4,
]);
const subscribed_streams = new Set([
stream_1.stream_id,
stream_2.stream_id,
stream_3.stream_id,
stream_4.stream_id,
]);
// Tests for get_channels_in_folders_matching_search_term_in_folder_name
// Should match 'Frontend' folder and return its streams
assert.deepEqual(
channel_folders.get_channels_in_folders_matching_search_term_in_folder_name(
"Front",
subscribed_streams,
),
[stream_2.stream_id, stream_4.stream_id],
);
// Should match 'Backend' folder and return no streams
assert.deepEqual(
channel_folders.get_channels_in_folders_matching_search_term_in_folder_name(
"Back",
subscribed_streams,
),
[],
);
// Should match no folder and return empty array
assert.deepEqual(
channel_folders.get_channels_in_folders_matching_search_term_in_folder_name(
"Nonexistent",
subscribed_streams,
),
[],
);
assert.deepEqual(channel_folders.user_has_folders(), true);
// Unarchive the folder for further tests.
devops_folder.is_archived = false;
assert.deepEqual(channel_folders.get_folders_with_accessible_channels(), [
devops_folder,
frontend_folder,
]);
// Remove a stream to test case of an inaccessible stream.
stream_data.delete_sub(stream_3.stream_id);
assert.deepEqual(channel_folders.get_folders_with_accessible_channels(), [frontend_folder]);
});

View File

@@ -0,0 +1,128 @@
"use strict";
const assert = require("node:assert/strict");
const {JSDOM} = require("jsdom");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {zrequire, mock_esm} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const settings_config = zrequire("settings_config");
mock_esm("../src/user_settings", {
user_settings: {
web_channel_default_view: settings_config.web_channel_default_view_values.channel_feed.code,
},
});
const clipboard_handler = zrequire("clipboard_handler");
const stream_data = zrequire("stream_data");
const people = zrequire("people");
const hamlet = make_user({
user_id: 15,
email: "hamlet@example.com",
full_name: "Hamlet",
});
people.add_active_user(hamlet);
run_test("copy_link_to_clipboard", async ({override}) => {
const stream = make_stream({
name: "Stream",
description: "Color and Lights",
stream_id: 1,
subscribed: true,
});
stream_data.add_sub_for_tests(stream);
const {window} = new JSDOM();
global.document = window.document;
// Mock DataTransfer for testing purposes
class MockDataTransfer {
constructor() {
this.data = {};
}
setData(type, value) {
this.data[type] = value;
}
getData(type) {
return this.data[type] || "";
}
}
// Store the copy event callback and clipboardData
let clipboardData;
let copyEventCallback;
override(window.document, "addEventListener", (event, callback) => {
if (event === "copy") {
copyEventCallback = callback;
}
});
// Stub execCommand to trigger the copy event
override(window.document, "execCommand", (command) => {
if (command === "copy" && copyEventCallback) {
const copyEvent = new window.Event("copy", {bubbles: true, cancelable: true});
copyEvent.clipboardData = new MockDataTransfer();
copyEventCallback(copyEvent);
clipboardData = copyEvent.clipboardData;
}
return true;
});
// Helper function to simulate clipboard handling
async function simulateClipboardData(link) {
await clipboard_handler.copy_link_to_clipboard(link);
return {
plainText: clipboardData.getData("text/plain"),
htmlText: clipboardData.getData("text/html"),
markdownText: clipboardData.getData("text/x-gfm"),
};
}
const normal_stream_with_topic =
"http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic";
const normal_stream_with_topic_and_message =
"http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic/near/1";
const normal_stream = "http://zulip.zulipdev.com/#narrow/channel/1-Stream/";
const dm_message = "http://zulip.zulipdev.com/#narrow/dm/15-dm/near/43";
let clipboardDataResult = await simulateClipboardData(normal_stream_with_topic);
assert.equal(clipboardDataResult.plainText, normal_stream_with_topic);
assert.equal(
clipboardDataResult.htmlText,
`<a href="http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic">#Stream > normal topic</a>`,
);
assert.equal(
clipboardDataResult.markdownText,
`[#Stream > normal topic](http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic)`,
);
clipboardDataResult = await simulateClipboardData(normal_stream_with_topic_and_message);
assert.equal(clipboardDataResult.plainText, normal_stream_with_topic_and_message);
assert.equal(
clipboardDataResult.htmlText,
`<a href="http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic/near/1">#Stream > normal topic @ 💬</a>`,
);
assert.equal(
clipboardDataResult.markdownText,
`[#Stream > normal topic @ 💬](http://zulip.zulipdev.com/#narrow/channel/1-Stream/topic/normal.20topic/near/1)`,
);
clipboardDataResult = await simulateClipboardData(normal_stream);
assert.equal(clipboardDataResult.plainText, normal_stream);
assert.equal(
clipboardDataResult.htmlText,
`<a href="http://zulip.zulipdev.com/#narrow/channel/1-Stream/">#Stream</a>`,
);
assert.equal(
clipboardDataResult.markdownText,
`[#Stream](http://zulip.zulipdev.com/#narrow/channel/1-Stream/)`,
);
clipboardDataResult = await simulateClipboardData(dm_message);
assert.equal(clipboardDataResult.plainText, dm_message);
});

View File

@@ -0,0 +1,36 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const color_data = zrequire("color_data");
run_test("pick_color", () => {
color_data.reset();
color_data.claim_colors([
{color: color_data.colors[1]},
{foo: "whatever"},
{color: color_data.colors[3]},
{color: "bogus"},
]);
const expected_colors = [
color_data.colors[0],
color_data.colors[2],
...color_data.colors.slice(4),
// ok, now we'll cycle through all colors
...color_data.colors,
...color_data.colors,
...color_data.colors,
];
for (const expected_color of expected_colors) {
assert.equal(color_data.pick_color(), expected_color);
}
color_data.claim_color(color_data.colors[0]);
assert.equal(color_data.pick_color(), color_data.colors[1]);
});

221
web/tests/common.test.cjs Normal file
View File

@@ -0,0 +1,221 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {$} = require("./lib/zjquery.cjs");
mock_esm("tippy.js", {
default(arg) {
arg._tippy = {setContent: noop};
return arg._tippy;
},
});
set_global("document", {});
const navigator = set_global("navigator", {});
const common = zrequire("common");
run_test("phrase_match", () => {
assert.ok(common.phrase_match("tes", "test"));
assert.ok(common.phrase_match("Tes", "test"));
assert.ok(common.phrase_match("Tes", "Test"));
assert.ok(common.phrase_match("tes", "Stream Test"));
assert.ok(common.phrase_match("", "test"));
assert.ok(!common.phrase_match("tests", "test"));
assert.ok(!common.phrase_match("tes", "hostess"));
});
run_test("adjust_mac_kbd_tags non-mac", ({override}) => {
override(navigator, "platform", "Windows");
// The adjust_mac_kbd_tags has a really simple guard
// at the top, and we just test the early-return behavior
// by trying to pass it garbage.
common.adjust_mac_kbd_tags("selector-that-does-not-exist");
});
run_test("adjust_mac_kbd_tags mac", ({override}) => {
const keys_to_test_mac = new Map([
["Backspace", "Delete"],
["Enter", "Return"],
["Ctrl", "⌘"],
["Alt", "⌥"],
["#stream_name", "#stream_name"],
["Ctrl+K", "Ctrl+K"],
["[", "["],
["X", "X"],
["data-mac-following-key", "data-mac-following-key"],
]);
override(navigator, "platform", "MacIntel");
$("<span>").set_contents($("<contents-stub>"));
const test_items = [];
let key_no = 1;
for (const [old_key, mac_key] of keys_to_test_mac) {
const test_item = {};
const $stub = $.create("hotkey_" + key_no);
$stub.text(old_key);
if (old_key === "data-mac-following-key") {
$stub.attr("data-mac-following-key", "⌥");
$stub[0].after = (plus, element) => {
assert.equal(plus.innerHTML, "<contents-stub>");
assert.equal(element.innerHTML, "<kbd>");
assert.equal(element.textContent, $stub.attr("data-mac-following-key"));
};
}
test_item.$stub = $stub;
test_item.mac_key = mac_key;
test_items.push(test_item);
key_no += 1;
}
const elements = test_items.map((test_item) => ({to_$: () => test_item.$stub}));
$.create(".markdown kbd", {elements});
common.adjust_mac_kbd_tags(".markdown kbd");
for (const test_item of test_items) {
assert.equal(test_item.$stub.text(), test_item.mac_key);
}
});
run_test("adjust_mac_hotkey_hints non-mac", ({override}) => {
override(navigator, "platform", "Windows");
// The adjust_mac_hotkey_hints has a really simple guard
// at the top, and we just test the early-return behavior
// by trying to pass it garbage.
common.adjust_mac_hotkey_hints("not-an-array");
});
// Test default values of adjust_mac_hotkey_hints
// Expected values
run_test("adjust_mac_hotkey_hints mac expected", ({override}) => {
const keys_to_test_mac = new Map([
[["Backspace"], ["Delete"]],
[["Enter"], ["Return"]],
[["Ctrl"], ["⌘"]],
]);
override(navigator, "platform", "MacIntel");
const test_items = [];
for (const [old_key, mac_key] of keys_to_test_mac) {
const test_item = {};
common.adjust_mac_hotkey_hints(old_key);
test_item.mac_key = mac_key;
test_item.adjusted_key = old_key;
test_items.push(test_item);
}
for (const test_item of test_items) {
assert.deepStrictEqual(test_item.mac_key, test_item.adjusted_key);
}
});
// Test non-default values of adjust_mac_hotkey_hints
// Random values
run_test("adjust_mac_hotkey_hints mac random", ({override}) => {
const keys_to_test_mac = new Map([
[
["Ctrl", "["],
["⌘", "["],
],
[
["Ctrl", "K"],
["⌘", "K"],
],
[
["Shift", "G"],
["Shift", "G"],
],
[["Space"], ["Space"]],
]);
override(navigator, "platform", "MacIntel");
const test_items = [];
for (const [old_key, mac_key] of keys_to_test_mac) {
const test_item = {};
common.adjust_mac_hotkey_hints(old_key);
test_item.mac_key = mac_key;
test_item.adjusted_key = old_key;
test_items.push(test_item);
}
for (const test_item of test_items) {
assert.deepStrictEqual(test_item.mac_key, test_item.adjusted_key);
}
});
run_test("reset password toggle icons with missing toggle", () => {
// Password manager extensions can rearrange the DOM around
// password inputs such that the toggle selector matches nothing.
const password_selector = "#mangled_password ~ .password_visibility_toggle";
$.create(password_selector, {elements: []});
blueslip.expect("error", "Password visibility toggle not found");
common.reset_password_toggle_icons("#mangled_password", password_selector);
assert.equal($("#mangled_password").attr("type"), "password");
});
run_test("show password", () => {
const password_selector = "#id_password ~ .password_visibility_toggle";
function set_attribute(type) {
$("#id_password").attr("type", type);
}
function check_assertion(type, present_class, absent_class) {
assert.equal($("#id_password").attr("type"), type);
assert.ok($(password_selector).hasClass(present_class));
assert.ok(!$(password_selector).hasClass(absent_class));
}
const click_ev = {
preventDefault() {},
stopPropagation() {},
};
const key_ev = {
key: "Enter",
preventDefault() {},
stopPropagation() {},
};
set_attribute("password");
common.setup_password_visibility_toggle("#id_password", password_selector);
const click_handler = $(password_selector).get_on_handler("click");
const key_handler = $(password_selector).get_on_handler("keydown");
click_handler(click_ev);
check_assertion("text", "zulip-icon-show", "zulip-icon-hide");
click_handler(click_ev);
check_assertion("password", "zulip-icon-hide", "zulip-icon-show");
key_handler(key_ev);
check_assertion("text", "zulip-icon-show", "zulip-icon-hide");
key_handler(key_ev);
check_assertion("password", "zulip-icon-hide", "zulip-icon-show");
click_handler(click_ev);
common.reset_password_toggle_icons("#id_password", password_selector);
check_assertion("password", "zulip-icon-hide", "zulip-icon-show");
});

View File

@@ -0,0 +1,325 @@
"use strict";
const assert = require("node:assert/strict");
const {$t} = require("./lib/i18n.cjs");
const {mock_esm, mock_jquery, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const ui_util = mock_esm("../src/ui_util");
let env;
function make_tab(i) {
const $self = {};
assert.equal(env.tabs.length, i);
$self.stub = true;
$self.class = [];
$self.visible = true;
$self.addClass = (c) => {
$self.class += " " + c;
const tokens = $self.class.trim().split(/ +/);
$self.class = [...new Set(tokens)].join(" ");
};
$self.removeClass = (c) => {
const tokens = $self.class.trim().split(/ +/);
$self.class = tokens.filter((token) => token !== c).join(" ");
};
$self.hasClass = (c) => {
const tokens = $self.class.trim().split(/ +/);
return tokens.includes(c);
};
$self.css = (prop) => {
assert.equal(prop, "display");
if ($self.visible) {
return "";
}
return "none";
};
$self.attr = (name) => {
assert.equal(name, "data-tab-id");
return i;
};
$self.text = (text) => {
assert.equal(
text,
[
"translated: Keyboard shortcuts",
"translated: Message formatting",
"translated: Search filters",
][i],
);
};
$self.trigger = (type) => {
if (type === "focus") {
env.focused_tab = i;
} else if (type === "click") {
env.click_f.call(env.tabs[i]);
}
};
env.tabs.push($self);
return $self;
}
const ind_tab = (function () {
return {
stub: true,
on(name, f) {
if (name === "click") {
env.click_f = f;
} else if (name === "keydown") {
env.keydown_f = f;
}
},
off(name) {
if (name === "click") {
env.click_f = undefined;
}
},
removeClass(c) {
for (const $tab of env.tabs) {
$tab.removeClass(c);
}
},
eq: (idx) => env.tabs[idx],
};
})();
function make_switcher() {
return {
stub: true,
children: [],
classList: new Set(),
append(child) {
this.children.push(child);
},
addClass(c) {
this.classList.add(c);
this.addedClass = c;
},
find(sel) {
switch (sel) {
case ".ind-tab":
return ind_tab;
/* istanbul ignore next */
default:
throw new Error("unknown selector: " + sel);
}
},
};
}
mock_jquery((sel) => {
if (sel.stub) {
// The component often redundantly re-wraps objects.
return sel;
}
switch (sel) {
case "<div>":
return {
addClass(className) {
if (className === "tab-switcher") {
return env.switcher;
}
assert.equal(className, "ind-tab");
return {
attr(attributes) {
const tab_id = attributes["data-tab-id"];
assert.deepEqual(
attributes,
[
{
"data-tab-key": "keyboard-shortcuts",
"data-tab-id": 0,
tabindex: 0,
},
{
"data-tab-key": "message-formatting",
"data-tab-id": 1,
tabindex: 0,
},
{
"data-tab-key": "search-operators",
"data-tab-id": 2,
tabindex: 0,
},
][tab_id],
);
return make_tab(tab_id);
},
};
},
};
/* istanbul ignore next */
default:
throw new Error("unknown selector: " + sel);
}
});
const components = zrequire("components");
const LEFT_KEY = {key: "ArrowLeft", preventDefault: noop, stopPropagation: noop};
const RIGHT_KEY = {key: "ArrowRight", preventDefault: noop, stopPropagation: noop};
const ENTER_KEY = {key: "Enter", preventDefault: noop, stopPropagation: noop};
run_test("basics", () => {
env = {
keydown_f: undefined,
click_f: undefined,
tabs: [],
focused_tab: undefined,
switcher: make_switcher(),
};
let callback_args;
let callback_value;
let widget = null;
widget = components.toggle({
selected: 0,
values: [
{label: $t({defaultMessage: "Keyboard shortcuts"}), key: "keyboard-shortcuts"},
{label: $t({defaultMessage: "Message formatting"}), key: "message-formatting"},
{label: $t({defaultMessage: "Search filters"}), key: "search-operators"},
],
html_class: "stream_sorter_toggle",
callback(name, key) {
assert.equal(callback_args, undefined);
callback_args = [name, key];
// The subs code tries to get a widget value in the middle of a
// callback, which can lead to obscure bugs.
if (widget) {
callback_value = widget.value();
}
},
});
assert.equal(widget.get(), env.switcher);
assert.deepEqual(env.switcher.children, env.tabs);
assert.equal(env.switcher.addedClass, "stream_sorter_toggle");
assert.equal(env.focused_tab, 0);
assert.equal(env.tabs[0].class, "first selected");
assert.equal(env.tabs[1].class, "middle");
assert.equal(env.tabs[2].class, "last");
assert.deepEqual(callback_args, ["translated: Keyboard shortcuts", "keyboard-shortcuts"]);
assert.equal(widget.value(), "translated: Keyboard shortcuts");
assert.equal(widget.key(), "keyboard-shortcuts");
callback_args = undefined;
widget.goto("message-formatting");
assert.equal(env.focused_tab, 1);
assert.equal(env.tabs[0].class, "first");
assert.equal(env.tabs[1].class, "middle selected");
assert.equal(env.tabs[2].class, "last");
assert.deepEqual(callback_args, ["translated: Message formatting", "message-formatting"]);
assert.equal(widget.value(), "translated: Message formatting");
assert.equal(widget.key(), "message-formatting");
// Go to same tab twice and make sure we get callback.
callback_args = undefined;
widget.goto("message-formatting");
assert.deepEqual(callback_args, ["translated: Message formatting", "message-formatting"]);
callback_args = undefined;
env.keydown_f.call(env.tabs[env.focused_tab], RIGHT_KEY);
assert.equal(env.focused_tab, 2);
assert.equal(env.tabs[0].class, "first");
assert.equal(env.tabs[1].class, "middle");
assert.equal(env.tabs[2].class, "last selected");
assert.deepEqual(callback_args, ["translated: Search filters", "search-operators"]);
assert.equal(widget.value(), "translated: Search filters");
assert.equal(widget.value(), callback_value);
// try to crash the key handler
env.keydown_f.call(env.tabs[env.focused_tab], RIGHT_KEY);
assert.equal(widget.value(), "translated: Search filters");
callback_args = undefined;
env.keydown_f.call(env.tabs[env.focused_tab], LEFT_KEY);
assert.equal(widget.value(), "translated: Message formatting");
callback_args = undefined;
env.keydown_f.call(env.tabs[env.focused_tab], LEFT_KEY);
assert.equal(widget.value(), "translated: Keyboard shortcuts");
// try to crash the key handler
env.keydown_f.call(env.tabs[env.focused_tab], LEFT_KEY);
assert.equal(widget.value(), "translated: Keyboard shortcuts");
callback_args = undefined;
widget.disable_tab("message-formatting");
env.keydown_f.call(env.tabs[env.focused_tab], RIGHT_KEY);
assert.equal(widget.value(), "translated: Search filters");
callback_args = undefined;
env.keydown_f.call(env.tabs[env.focused_tab], LEFT_KEY);
assert.equal(widget.value(), "translated: Keyboard shortcuts");
ui_util.convert_enter_to_click = () => {
env.tabs[2].trigger("click");
};
callback_args = undefined;
env.keydown_f.call(env.tabs[2], ENTER_KEY);
assert.equal(widget.value(), "translated: Search filters");
widget.enable_tab("message-formatting");
callback_args = undefined;
env.tabs[1].visible = false;
env.keydown_f.call(env.tabs[env.focused_tab], LEFT_KEY);
assert.equal(widget.value(), "translated: Keyboard shortcuts");
env.tabs[1].visible = true;
callback_args = undefined;
env.click_f.call(env.tabs[1]);
assert.equal(widget.value(), "translated: Message formatting");
callback_args = undefined;
widget.disable_tab("search-operators");
assert.equal(env.tabs[2].hasClass("disabled"), true);
assert.equal(env.tabs[2].class, "last disabled");
widget.goto("keyboard-shortcuts");
assert.equal(env.focused_tab, 0);
widget.goto("search-operators");
assert.equal(env.focused_tab, 0);
blueslip.expect("warn", "Incorrect tab name given.", 3);
widget.disable_tab("incorrect-tab");
widget.enable_tab("incorrect-tab");
widget.goto("incorrect-tab");
});

883
web/tests/compose.test.cjs Normal file
View File

@@ -0,0 +1,883 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_banners} = require("./lib/compose_banner.cjs");
const {FakeComposeBox} = require("./lib/compose_helpers.cjs");
const {make_user_group} = require("./lib/example_group.cjs");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_bot, make_user} = require("./lib/example_user.cjs");
const {clock, mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
const user_groups = zrequire("user_groups");
set_global("document", {
querySelector() {},
});
set_global("navigator", {});
set_global(
"ResizeObserver",
class ResizeObserver {
observe() {}
},
);
const fake_now = 555;
const autosize = noop;
autosize.update = noop;
mock_esm("autosize", {default: autosize});
const channel = mock_esm("../src/channel");
const compose_actions = mock_esm("../src/compose_actions", {
register_compose_cancel_hook: noop,
register_compose_box_clear_hook: noop,
});
const compose_fade = mock_esm("../src/compose_fade");
const compose_notifications = mock_esm("../src/compose_notifications");
const compose_pm_pill = mock_esm("../src/compose_pm_pill");
const loading = mock_esm("../src/loading");
const markdown = mock_esm("../src/markdown");
const narrow_state = mock_esm("../src/narrow_state");
const rendered_markdown = mock_esm("../src/rendered_markdown");
const resize = mock_esm("../src/resize");
const sent_messages = mock_esm("../src/sent_messages");
const server_events_state = mock_esm("../src/server_events_state");
const transmit = mock_esm("../src/transmit");
const upload = mock_esm("../src/upload");
const onboarding_steps = mock_esm("../src/onboarding_steps", {
ONE_TIME_NOTICES_TO_DISPLAY: new Set(),
});
mock_esm("../src/settings_data", {
user_has_permission_for_group_setting: () => true,
});
const compose_ui = zrequire("compose_ui");
zrequire("compose_banner");
const compose_closed_ui = zrequire("compose_closed_ui");
const compose_recipient = zrequire("compose_recipient");
const compose_state = zrequire("compose_state");
const compose = zrequire("compose");
const compose_setup = zrequire("compose_setup");
const drafts = zrequire("drafts");
const echo = zrequire("echo");
const people = zrequire("people");
const {set_current_user, set_realm} = zrequire("state_data");
const stream_data = zrequire("stream_data");
const compose_validate = zrequire("compose_validate");
const {initialize_user_settings} = zrequire("user_settings");
const realm = make_realm({realm_topics_policy: "allow_empty_topic"});
set_realm(realm);
const current_user = {};
set_current_user(current_user);
const user_settings = {};
initialize_user_settings({user_settings});
function reset_jquery() {
// Avoid leaks.
$.clear_all_elements();
}
const new_user = make_user({
email: "new_user@example.com",
user_id: 101,
full_name: "New User",
date_joined: new Date(),
});
const me = make_user({
email: "me@example.com",
user_id: 30,
full_name: "Me Myself",
date_joined: new Date(),
});
const alice = make_user({
email: "alice@example.com",
user_id: 31,
full_name: "Alice",
});
const bob = make_user({
email: "bob@example.com",
user_id: 32,
full_name: "Bob",
});
const bot = make_bot({
email: "bot@example.com",
user_id: 33,
full_name: "Bot",
});
people.add_active_user(new_user);
people.add_active_user(me);
people.initialize_current_user(me.user_id);
people.add_active_user(alice);
people.add_active_user(bob);
people.add_active_user(bot);
const social = make_stream({
stream_id: 101,
name: "social",
subscribed: true,
can_send_message_group: 2,
topics_policy: "inherit",
});
stream_data.add_sub_for_tests(social);
const nobody = make_user_group({
name: "role:nobody",
id: 1,
members: new Set(),
is_system_group: true,
direct_subgroup_ids: new Set(),
});
const everyone = make_user_group({
name: "role:everyone",
id: 2,
members: new Set([30, 101]),
is_system_group: true,
direct_subgroup_ids: new Set(),
});
user_groups.initialize({realm_user_groups: [nobody, everyone]});
function test_ui(label, f) {
// TODO: initialize data more aggressively.
run_test(label, f);
}
function initialize_handlers({override}) {
override(realm, "realm_available_video_chat_providers", {disabled: {id: 0}});
override(realm, "realm_video_chat_provider", 0);
override(resize, "watch_manual_resize", noop);
disable_window_triggers(override);
compose_setup.initialize();
}
function disable_window_triggers(override) {
override(window, "to_$", () => $("window-stub"));
}
function simulate_draft_ui_interactions() {
// Simulate DOM relationships so that code can execute,
// but we won't actually examine these values.
$(".top_left_drafts").set_find_results(".unread_count", $.create("draft-unread-count-stub"));
}
test_ui("send_message_success", ({override, override_rewire}) => {
mock_banners();
const fake_compose_box = new FakeComposeBox();
let draft_deleted;
let reify_message_id_checked;
function reset() {
fake_compose_box.reset();
draft_deleted = false;
reify_message_id_checked = false;
}
reset();
const draft_model = drafts.draft_model;
override(draft_model, "deleteDrafts", (draft_ids) => {
assert.deepEqual(draft_ids, [100]);
draft_deleted = true;
});
override_rewire(echo, "reify_message_id", (local_id, message_id) => {
assert.equal(local_id, "1001");
assert.equal(message_id, 12);
reify_message_id_checked = true;
});
override(
onboarding_steps,
"ONE_TIME_NOTICES_TO_DISPLAY",
new Set(["visibility_policy_banner"]),
);
override(compose_notifications, "notify_automatic_new_visibility_policy", (message, data) => {
assert.equal(message.type, "stream");
assert.equal(message.stream_id, 1);
assert.equal(message.topic, "test");
assert.equal(data.id, 12);
assert.equal(data.automatic_new_visibility_policy, 2);
});
let request = {
locally_echoed: false,
local_id: "1001",
draft_id: 100,
type: "stream",
stream_id: 1,
topic: "test",
};
let data = {id: 12, automatic_new_visibility_policy: 2};
compose.send_message_success(request, data);
assert.equal(fake_compose_box.textarea_val(), "");
assert.ok(fake_compose_box.is_textarea_focused());
assert.ok(!fake_compose_box.is_submit_button_spinner_visible());
assert.ok(reify_message_id_checked);
assert.ok(draft_deleted);
reset();
override(compose_notifications, "get_muted_narrow", (message) => {
assert.equal(message.type, "stream");
assert.equal(message.stream_id, 2);
assert.equal(message.topic, "test");
});
request = {
locally_echoed: false,
local_id: "1001",
draft_id: 100,
type: "stream",
stream_id: 2,
topic: "test",
};
data = {id: 12};
compose.send_message_success(request, data);
assert.equal(fake_compose_box.textarea_val(), "");
assert.ok(fake_compose_box.is_textarea_focused());
assert.ok(!fake_compose_box.is_submit_button_spinner_visible());
assert.ok(reify_message_id_checked);
assert.ok(draft_deleted);
});
test_ui("send_message", ({override, override_rewire, mock_template}) => {
mock_banners();
clock.setSystemTime(new Date(fake_now * 1000));
const fake_compose_box = new FakeComposeBox();
// Draft UI side-effects are out of the scope of this test,
// but we need the code to not fail.
// TODO: probably mock at a higher level.
simulate_draft_ui_interactions();
// This is the common setup stuff for all of the four tests.
let stub_state;
function initialize_state_stub_dict() {
stub_state = {
send_msg_called: 0,
get_events_running_called: 0,
reify_message_id_checked: 0,
};
return stub_state;
}
override_rewire(drafts, "update_compose_draft_count", noop);
override(server_events_state, "assert_get_events_running", () => {
stub_state.get_events_running_called += 1;
});
override_rewire(drafts, "update_draft", () => 100);
override(drafts.draft_model, "getDraft", (draft_id) => {
assert.equal(draft_id, 100);
return {};
});
// Tests start here.
(function test_message_send_success_codepath() {
stub_state = initialize_state_stub_dict();
compose_state.topic("");
compose_state.set_message_type("private");
override(current_user, "user_id", new_user.user_id);
override(compose_pm_pill, "get_emails", () => "alice@example.com");
override(compose_pm_pill, "get_user_ids", () => [alice.user_id]);
const server_message_id = 127;
override(markdown, "render", noop);
override(markdown, "get_topic_links", () => []);
override_rewire(echo, "try_deliver_locally", (message_request) => {
const local_id_float = 123.04;
return echo.insert_local_message(message_request, local_id_float, (messages_data) => {
assert.equal(messages_data.type, "local_message");
assert.equal(messages_data.raw_messages[0].timestamp, fake_now);
return messages_data.raw_messages;
});
});
override(transmit, "send_message", (payload, success) => {
const single_msg = {
type: "private",
content: "[foobar](/user_uploads/123456)",
sender_id: new_user.user_id,
queue_id: undefined,
resend: false,
stream_id: undefined,
topic: "",
to: `[${alice.user_id}]`,
reply_to: "alice@example.com",
private_message_recipient: "alice@example.com",
to_user_ids: "31",
draft_id: 100,
local_id: "123.04",
locally_echoed: true,
};
assert.deepEqual(payload, single_msg);
payload.id = server_message_id;
success(payload);
stub_state.send_msg_called += 1;
});
override_rewire(echo, "reify_message_id", (local_id, message_id) => {
assert.equal(typeof local_id, "string");
assert.equal(typeof message_id, "number");
assert.equal(message_id, server_message_id);
stub_state.reify_message_id_checked += 1;
});
fake_compose_box.set_textarea_val("[foobar](/user_uploads/123456)");
fake_compose_box.blur_textarea();
fake_compose_box.show_submit_button_spinner();
compose.send_message();
const state = {
get_events_running_called: 1,
reify_message_id_checked: 1,
send_msg_called: 1,
};
assert.deepEqual(stub_state, state);
assert.equal(fake_compose_box.textarea_val(), "");
assert.ok(fake_compose_box.is_textarea_focused());
assert.ok(!fake_compose_box.is_submit_button_spinner_visible());
})();
// This is the additional setup which is common to both the tests below.
override(transmit, "send_message", (_payload, _success, error) => {
stub_state.send_msg_called += 1;
error("Error sending message: Server says 408");
});
let echo_error_msg_checked;
override_rewire(echo, "message_send_error", (local_id, error_response) => {
assert.equal(local_id, 123.04);
assert.equal(error_response, "Error sending message: Server says 408");
echo_error_msg_checked = true;
});
// Tests start here.
(function test_param_error_function_passed_from_send_message() {
stub_state = initialize_state_stub_dict();
compose.send_message();
const state = {
get_events_running_called: 1,
reify_message_id_checked: 0,
send_msg_called: 1,
};
assert.deepEqual(stub_state, state);
assert.ok(echo_error_msg_checked);
})();
(function test_error_codepath_local_id_undefined() {
let banner_rendered = false;
mock_template("compose_banner/compose_banner.hbs", false, (data) => {
assert.equal(data.classname, "generic_compose_error");
assert.equal(data.banner_text, "Error sending message: Server says 408");
banner_rendered = true;
return "<banner-stub>";
});
stub_state = initialize_state_stub_dict();
fake_compose_box.reset();
echo_error_msg_checked = false;
override_rewire(echo, "try_deliver_locally", noop);
override(sent_messages, "get_new_local_id", () => "loc-55");
compose.send_message();
const state = {
get_events_running_called: 1,
reify_message_id_checked: 0,
send_msg_called: 1,
};
assert.deepEqual(stub_state, state);
assert.ok(!echo_error_msg_checked);
assert.ok(banner_rendered);
assert.equal(fake_compose_box.textarea_val(), "default message");
assert.ok(fake_compose_box.is_textarea_focused());
assert.ok(!fake_compose_box.is_submit_button_spinner_visible());
})();
});
test_ui("handle_enter_key_with_preview_open", ({override, override_rewire}) => {
mock_banners();
window.addEventListener = noop;
let show_button_spinner_called = false;
const fake_compose_box = new FakeComposeBox();
override(loading, "show_button_spinner", ($spinner) => {
assert.equal($spinner.selector, fake_compose_box.compose_spinner_selector());
show_button_spinner_called = true;
});
override(current_user, "user_id", new_user.user_id);
// Test sending a message with content.
compose_state.set_message_type("stream");
compose_state.set_stream_id(social.stream_id);
fake_compose_box.set_textarea_val("message me");
fake_compose_box.show_message_preview();
override(user_settings, "enter_sends", true);
let send_message_called = false;
override_rewire(compose, "send_message", () => {
send_message_called = true;
});
override(realm, "realm_topics_policy", "allow_empty_topic");
compose.handle_enter_key_with_preview_open();
// Preview mode should remain on after finish() returns, because
// clear_preview_area() is now called inside clear_compose_box(),
// which only runs when the server confirms the send.
fake_compose_box.assert_preview_mode_is_on();
assert.ok(send_message_called);
assert.ok(show_button_spinner_called);
// Verify that preview mode is cleared when the compose box is
// cleared, as would happen asynchronously on send success.
compose.clear_compose_box();
fake_compose_box.assert_preview_mode_is_off();
override(user_settings, "enter_sends", false);
fake_compose_box.blur_textarea();
compose.handle_enter_key_with_preview_open();
assert.ok(fake_compose_box.is_textarea_focused());
// Test sending a message without content.
fake_compose_box.set_textarea_val("");
fake_compose_box.show_message_preview();
override(user_settings, "enter_sends", true);
compose.handle_enter_key_with_preview_open();
});
test_ui("finish", ({override, override_rewire}) => {
mock_banners();
const fake_compose_box = new FakeComposeBox();
let show_button_spinner_called = false;
override(loading, "show_button_spinner", ($spinner) => {
assert.equal($spinner.selector, fake_compose_box.compose_spinner_selector());
show_button_spinner_called = true;
});
(function test_when_compose_validation_fails() {
// To trigger the empty banner error instead of other errors
// set as per the priority.
override(current_user, "user_id", new_user.user_id);
compose_state.set_stream_id(social.stream_id);
fake_compose_box.set_topic_val("lunch");
fake_compose_box.set_textarea_val("burrito");
compose_state.set_message_type("stream");
assert.ok(!fake_compose_box.$content_textarea.hasClass("invalid"));
fake_compose_box.set_textarea_val("");
override_rewire(compose_ui, "compose_spinner_visible", false);
const res = compose.finish();
assert.equal(res, false);
assert.ok(fake_compose_box.$content_textarea.hasClass("invalid"));
assert.ok(!fake_compose_box.is_recipient_not_subscribed_banner_visible());
assert.ok(!fake_compose_box.is_submit_button_spinner_visible());
assert.ok(show_button_spinner_called);
})();
(function test_when_compose_validation_succeed() {
// Testing successfully sending of a message.
fake_compose_box.show_message_preview();
fake_compose_box.set_textarea_val("default message");
override_rewire(compose_ui, "compose_spinner_visible", false);
compose_state.set_message_type("private");
override(compose_pm_pill, "get_user_ids", () => [bob.user_id]);
override(realm, "realm_direct_message_permission_group", everyone.id);
override(realm, "realm_direct_message_initiator_group", everyone.id);
let send_message_called = false;
override_rewire(compose, "send_message", () => {
send_message_called = true;
});
assert.ok(compose.finish());
// Preview mode should remain on after finish() returns, because
// clear_preview_area() is now called inside clear_compose_box(),
// which only runs when the server confirms the send.
fake_compose_box.assert_preview_mode_is_on();
assert.ok(send_message_called);
// Verify that preview mode is cleared when the compose box is
// cleared, as would happen asynchronously on send success.
compose.clear_compose_box();
fake_compose_box.assert_preview_mode_is_off();
})();
});
test_ui("initialize", ({override}) => {
disable_window_triggers(override);
let compose_actions_expected_opts;
let compose_actions_start_checked;
override(compose_actions, "start", (opts) => {
assert.deepEqual(opts, compose_actions_expected_opts);
compose_actions_start_checked = true;
});
// In this test we mostly do the setup stuff in addition to testing the
// normal workflow of the function. All the tests for the on functions are
// done in subsequent tests directly below this test.
override(realm, "realm_available_video_chat_providers", {disabled: {id: 0}});
override(realm, "realm_video_chat_provider", 0);
let resize_watch_manual_resize_checked = false;
override(resize, "watch_manual_resize", (elem) => {
assert.equal("#compose-textarea", elem);
resize_watch_manual_resize_checked = true;
});
override(realm, "max_file_upload_size_mib", 512);
let uppy_cancel_all_called = false;
override(upload, "compose_upload_cancel", () => {
uppy_cancel_all_called = true;
});
compose_setup.initialize();
assert.ok(resize_watch_manual_resize_checked);
function set_up_compose_start_mock(expected_opts) {
compose_actions_start_checked = false;
compose_actions_expected_opts = {
...expected_opts,
message_type: "stream",
};
}
(function test_page_params_narrow_path() {
page_params.narrow = true;
reset_jquery();
set_up_compose_start_mock({});
compose_setup.initialize();
assert.ok(compose_actions_start_checked);
})();
(function test_page_params_narrow_topic() {
page_params.narrow_topic = "testing";
reset_jquery();
set_up_compose_start_mock({topic: "testing"});
compose_setup.initialize();
assert.ok(compose_actions_start_checked);
})();
(function test_abort_xhr() {
reset_jquery();
compose_setup.initialize();
compose_setup.abort_xhr();
assert.ok(uppy_cancel_all_called);
})();
});
test_ui("update_draft_if_composing", ({override_rewire}) => {
let update_draft_call_count = 0;
override_rewire(drafts, "update_draft", (opts) => {
assert.deepEqual(opts, {no_notify: true});
update_draft_call_count += 1;
return "draft-id";
});
// Autosave does nothing once the compose box has closed, so a
// delayed call can't resurrect a draft the user is done with.
compose_state.set_message_type(undefined);
assert.ok(!compose_state.composing());
compose_setup.update_draft_if_composing();
assert.equal(update_draft_call_count, 0);
// While the user is still composing, autosave persists the draft.
compose_state.set_message_type("stream");
assert.ok(compose_state.composing());
compose_setup.update_draft_if_composing();
assert.equal(update_draft_call_count, 1);
});
test_ui("update_fade", ({override, override_rewire}) => {
mock_banners();
initialize_handlers({override});
let set_focused_recipient_checked = false;
let update_all_called = false;
let update_narrow_to_recipient_visibility_called = false;
override(compose_fade, "set_focused_recipient", (msg_type) => {
assert.equal(msg_type, "private");
set_focused_recipient_checked = true;
});
override(compose_fade, "update_all", () => {
update_all_called = true;
});
override_rewire(compose_recipient, "update_narrow_to_recipient_visibility", () => {
update_narrow_to_recipient_visibility_called = true;
});
override_rewire(compose_validate, "validate_and_update_send_button_status", noop);
override_rewire(drafts, "update_compose_draft_count", noop);
override(compose_pm_pill, "get_user_ids", () => []);
compose_state.set_message_type(undefined);
compose_recipient.update_on_recipient_change();
assert.ok(!set_focused_recipient_checked);
assert.ok(!update_all_called);
assert.ok(update_narrow_to_recipient_visibility_called);
update_narrow_to_recipient_visibility_called = false;
compose_state.set_message_type("private");
compose_recipient.update_on_recipient_change();
assert.ok(set_focused_recipient_checked);
assert.ok(update_all_called);
assert.ok(update_narrow_to_recipient_visibility_called);
});
test_ui("trigger_submit_compose_form", ({override, override_rewire}) => {
initialize_handlers({override});
let prevent_default_checked = false;
let compose_finish_checked = false;
override_rewire(compose, "finish", () => {
compose_finish_checked = true;
});
new FakeComposeBox().trigger_submit_handler_on_compose_form({
preventDefault() {
prevent_default_checked = true;
},
});
assert.ok(prevent_default_checked);
assert.ok(compose_finish_checked);
});
test_ui("on_events", ({override, override_rewire}) => {
initialize_handlers({override});
override(rendered_markdown, "update_elements", noop);
const fake_compose_box = new FakeComposeBox();
(function test_attach_files_compose_clicked() {
let compose_file_input_clicked = false;
// For some reason clicking on the attachment icon
// triggers a click handler on #compose .file_input
// as part of our upload code, and we verify this
// codepath.
fake_compose_box.set_click_handler_for_upload_file_input_element(() => {
compose_file_input_clicked = true;
});
fake_compose_box.click_on_upload_attachment_icon({
preventDefault: noop,
stopPropagation: noop,
});
assert.ok(compose_file_input_clicked);
})();
(function test_markdown_preview_compose_clicked() {
$("#compose .preview_content").set_find_results(
".image-loading-placeholder",
$.create("no-images", {elements: []}),
);
function setup_mock_markdown_contains_backend_only_syntax(msg_content, return_val) {
override(markdown, "contains_backend_only_syntax", (msg) => {
assert.equal(msg, msg_content);
return return_val;
});
}
function setup_mock_markdown_is_status_message(msg_content, return_val) {
override(markdown, "is_status_message", (content) => {
assert.equal(content, msg_content);
return return_val;
});
}
function test_post_success(success_callback) {
const resp = {
msg: "",
result: "success",
rendered: "Server: default message",
};
success_callback(resp);
assert.equal(fake_compose_box.preview_content_html(), "Server: default message");
}
function test_post_error(error_callback) {
error_callback();
assert.equal(
fake_compose_box.preview_content_html(),
"translated: Failed to generate preview",
);
}
let current_message;
override(channel, "post", (payload) => {
assert.equal(payload.url, "/json/messages/render");
assert.ok(payload.data);
assert.deepEqual(payload.data.content, current_message);
function test(func, param) {
let destroy_indicator_called = false;
override(loading, "destroy_indicator", ($spinner) => {
assert.equal($spinner.selector, fake_compose_box.markdown_spinner_selector());
destroy_indicator_called = true;
});
setup_mock_markdown_contains_backend_only_syntax(current_message, true);
func(param);
assert.ok(destroy_indicator_called);
}
test(test_post_error, payload.error);
test(test_post_success, payload.success);
});
// Tests start here
fake_compose_box.set_textarea_val("");
fake_compose_box.hide_message_preview();
fake_compose_box.click_on_markdown_preview_icon({
preventDefault: noop,
stopPropagation: noop,
});
assert.equal(fake_compose_box.preview_content_html(), "translated: Nothing to preview");
fake_compose_box.assert_preview_mode_is_on();
let make_indicator_called = false;
fake_compose_box.set_textarea_val("```default message```");
fake_compose_box.hide_message_preview();
setup_mock_markdown_contains_backend_only_syntax("```default message```", true);
setup_mock_markdown_is_status_message("```default message```", false);
override(loading, "make_indicator", ($spinner) => {
assert.equal($spinner.selector, fake_compose_box.markdown_spinner_selector());
make_indicator_called = true;
});
current_message = "```default message```";
fake_compose_box.click_on_markdown_preview_icon({
preventDefault: noop,
stopPropagation: noop,
});
assert.ok(make_indicator_called);
fake_compose_box.assert_preview_mode_is_on();
let render_called = false;
fake_compose_box.set_textarea_val("default message");
fake_compose_box.hide_message_preview();
setup_mock_markdown_contains_backend_only_syntax("default message", false);
setup_mock_markdown_is_status_message("default message", false);
current_message = "default message";
override(markdown, "render", (raw_content) => {
assert.equal(raw_content, "default message");
render_called = true;
return {content: "Local: default message"};
});
fake_compose_box.click_on_markdown_preview_icon({
preventDefault: noop,
stopPropagation: noop,
});
assert.ok(render_called);
fake_compose_box.assert_preview_mode_is_on();
assert.equal(fake_compose_box.preview_content_html(), "Server: default message");
})();
(function test_undo_markdown_preview_clicked() {
fake_compose_box.show_message_preview();
override_rewire(compose_recipient, "update_compose_area_placeholder_text", noop);
override(compose_fade, "do_update_all", noop);
override(narrow_state, "narrowed_by_reply", () => true);
override(
compose_notifications,
"maybe_show_one_time_non_interleaved_view_messages_fading_banner",
noop,
);
fake_compose_box.click_on_undo_markdown_preview_icon({
preventDefault: noop,
stopPropagation: noop,
});
fake_compose_box.assert_preview_mode_is_off();
})();
});
test_ui("DM policy disabled", ({override}) => {
// Disable sending direct messages in the organisation
override(realm, "realm_direct_message_permission_group", nobody.id);
override(realm, "realm_direct_message_initiator_group", everyone.id);
// For single bot recipient, Bot, the "Message X" button is not disabled
let reply_disabled =
compose_closed_ui.should_disable_compose_reply_button_for_direct_message("33");
assert.ok(!reply_disabled);
// For human user, Alice, the "Message X" button is disabled
reply_disabled = compose_closed_ui.should_disable_compose_reply_button_for_direct_message("31");
assert.ok(reply_disabled);
// For human user and bot user, the "Message X" button is disabled
reply_disabled =
compose_closed_ui.should_disable_compose_reply_button_for_direct_message("31,33");
assert.ok(reply_disabled);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,506 @@
"use strict";
const assert = require("node:assert/strict");
const events = require("./lib/events.cjs");
const {make_realm} = require("./lib/example_realm.cjs");
const {mock_esm, set_global, with_overrides, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
const channel = mock_esm("../src/channel");
const compose_closed_ui = mock_esm("../src/compose_closed_ui");
const compose_ui = mock_esm("../src/compose_ui");
mock_esm("../src/resize", {
watch_manual_resize() {},
});
set_global("document", {
querySelector() {},
});
set_global("navigator", {});
set_global(
"ResizeObserver",
class ResizeObserver {
observe() {}
},
);
const server_events_dispatch = zrequire("server_events_dispatch");
const compose_setup = zrequire("compose_setup");
const {set_current_user, set_realm} = zrequire("state_data");
const realm = make_realm();
set_realm(realm);
const current_user = {};
set_current_user(current_user);
const realm_available_video_chat_providers = {
disabled: {
id: 0,
name: "disabled",
},
jitsi_meet: {
id: 1,
name: "Jitsi Meet",
},
zoom: {
id: 3,
name: "Zoom",
},
big_blue_button: {
id: 4,
name: "BigBlueButton",
},
constructor_groups: {
id: 6,
name: "Constructor Groups",
},
nextcloud_talk: {
id: 7,
name: "Nextcloud Talk",
},
webex: {
id: 8,
name: "Webex",
},
};
function test(label, f) {
run_test(label, (helpers) => {
helpers.override(
realm,
"realm_available_video_chat_providers",
realm_available_video_chat_providers,
);
f(helpers);
});
}
test("videos", ({override}) => {
override(realm, "realm_video_chat_provider", realm_available_video_chat_providers.disabled.id);
override(window, "to_$", () => $("window-stub"));
compose_setup.initialize();
(function test_no_provider_video_link_compose_clicked() {
const $textarea = $.create("target-stub");
$textarea.set_parents_result(".message_edit_form", []);
const ev = {
preventDefault() {},
stopPropagation() {},
};
const handler = $("body").get_on_handler("click", ".video_link");
$("textarea#compose-textarea").val("");
with_overrides(({disallow}) => {
disallow(compose_ui, "insert_syntax_and_focus");
handler(ev);
});
})();
(function test_jitsi_video_link_compose_clicked() {
let syntax_to_insert;
let called = false;
const $textarea = $.create("jitsi-target-stub");
$textarea.set_parents_result(".message_edit_form", []);
const ev = {
preventDefault() {},
stopPropagation() {},
};
override(compose_ui, "insert_syntax_and_focus", (syntax) => {
syntax_to_insert = syntax;
called = true;
});
const handler = $("body").get_on_handler("click", ".video_link");
$("textarea#compose-textarea").val("");
override(
realm,
"realm_video_chat_provider",
realm_available_video_chat_providers.jitsi_meet.id,
);
override(realm, "realm_jitsi_server_url", null);
override(realm, "server_jitsi_server_url", null);
handler(ev);
assert.ok(!called);
override(realm, "realm_jitsi_server_url", null);
override(realm, "server_jitsi_server_url", "https://server.example.com");
handler.call($textarea, ev);
// video link ids consist of 15 random digits
let video_link_regex =
/\[translated: Join video call\.]\(https:\/\/server.example.com\/\d{15}#config.startWithVideoMuted=false\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
override(realm, "realm_jitsi_server_url", "https://realm.example.com");
override(realm, "server_jitsi_server_url", null);
handler.call($textarea, ev);
video_link_regex =
/\[translated: Join video call\.]\(https:\/\/realm.example.com\/\d{15}#config.startWithVideoMuted=false\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
override(realm, "realm_jitsi_server_url", "https://realm.example.com");
override(realm, "server_jitsi_server_url", "https://server.example.com");
handler.call($textarea, ev);
video_link_regex =
/\[translated: Join video call\.]\(https:\/\/realm.example.com\/\d{15}#config.startWithVideoMuted=false\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
// Scenario where the URL has a path segment (like an auth token)
override(realm, "realm_jitsi_server_url", "https://jitsi.example.com/custom-token");
handler.call($textarea, ev);
video_link_regex =
/\[translated: Join video call\.\]\(https:\/\/jitsi\.example\.com\/custom-token\/\d{15}#config\.startWithVideoMuted=false\)/;
assert.match(syntax_to_insert, video_link_regex);
})();
(function test_zoom_video_and_audio_links_compose_clicked() {
let syntax_to_insert;
let called = false;
const $textarea = $.create("zoom-target-stub");
$textarea.set_parents_result(".message_edit_form", []);
const ev = {
preventDefault() {},
stopPropagation() {},
};
override(compose_ui, "insert_syntax_and_focus", (syntax) => {
syntax_to_insert = syntax;
called = true;
success_callback = undefined;
});
override(realm, "realm_video_chat_provider", realm_available_video_chat_providers.zoom.id);
override(current_user, "has_zoom_token", false);
window.open = (url) => {
assert.ok(url.endsWith("/calls/zoom/register"));
// The event here has value=true. We keep it in events.js to
// allow our tooling to verify its schema.
server_events_dispatch.dispatch_normal_event(events.fixtures.has_zoom_token);
};
let success_callback;
const xhr_object = {abort() {}};
channel.post = (payload) => {
assert.equal(payload.url, "/json/calls/zoom/create");
success_callback = payload.success;
return xhr_object;
};
function call_success_callback() {
assert.ok(success_callback !== undefined);
success_callback({
result: "success",
msg: "",
url: "example.zoom.com",
});
}
$("textarea#compose-textarea").val("");
const video_handler = $("body").get_on_handler("click", ".video_link");
video_handler.call($textarea, ev);
call_success_callback();
const video_link_regex = /\[translated: Join video call\.]\(example\.zoom\.com\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
$("textarea#compose-textarea").val("");
const audio_handler = $("body").get_on_handler("click", ".audio_link");
audio_handler.call($textarea, ev);
call_success_callback();
const audio_link_regex = /\[translated: Join voice call\.]\(example\.zoom\.com\)/;
assert.ok(called);
assert.match(syntax_to_insert, audio_link_regex);
})();
(function test_webex_video_link_compose_clicked() {
let syntax_to_insert;
let called = false;
const $textarea = $.create("webex-target-stub");
$textarea.set_parents_result(".message_edit_form", []);
const ev = {
preventDefault() {},
stopPropagation() {},
};
override(compose_ui, "insert_syntax_and_focus", (syntax) => {
syntax_to_insert = syntax;
called = true;
success_callback = undefined;
});
override(realm, "realm_video_chat_provider", realm_available_video_chat_providers.webex.id);
override(current_user, "has_webex_token", false);
window.open = (url) => {
assert.ok(url.endsWith("/calls/webex/register"));
// The event here has value=true. We keep it in events.js to
// allow our tooling to verify its schema.
server_events_dispatch.dispatch_normal_event(events.fixtures.has_webex_token);
};
let success_callback;
const xhr_object = {abort() {}};
channel.post = (payload) => {
assert.equal(payload.url, "/json/calls/webex/create");
success_callback = payload.success;
return xhr_object;
};
function call_success_callback() {
assert.ok(success_callback !== undefined);
success_callback({
result: "success",
msg: "",
url: "example.webex.com",
});
}
$("textarea#compose-textarea").val("");
const video_handler = $("body").get_on_handler("click", ".video_link");
video_handler.call($textarea, ev);
call_success_callback();
const video_link_regex = /\[translated: Join video call\.]\(example\.webex\.com\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
})();
(function test_bbb_audio_and_video_links_compose_clicked() {
let syntax_to_insert;
let called = false;
const $textarea = $.create("bbb-target-stub");
$textarea.set_parents_result(".message_edit_form", []);
const ev = {
preventDefault() {},
stopPropagation() {},
};
override(compose_ui, "insert_syntax_and_focus", (syntax) => {
syntax_to_insert = syntax;
called = true;
success_callback = undefined;
url = undefined;
});
$("textarea#compose-textarea").val("");
override(
realm,
"realm_video_chat_provider",
realm_available_video_chat_providers.big_blue_button.id,
);
override(compose_closed_ui, "get_recipient_label", () => ({label_text: "a"}));
let success_callback;
const xhr_object = {abort() {}};
let url;
channel.get = (options) => {
assert.equal(options.url, "/json/calls/bigbluebutton/create");
assert.equal(options.data.meeting_name, "a meeting");
success_callback = options.success;
url =
"/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=" +
options.data.voice_only +
"&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22";
return xhr_object;
};
function call_success_callback() {
assert.ok(success_callback !== undefined);
success_callback({
result: "success",
msg: "",
url,
});
}
$("textarea#compose-textarea").val("");
const video_handler = $("body").get_on_handler("click", ".video_link");
video_handler.call($textarea, ev);
call_success_callback();
const video_link_regex =
/\[translated: Join video call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=false&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
const audio_handler = $("body").get_on_handler("click", ".audio_link");
audio_handler.call($textarea, ev);
call_success_callback();
const audio_link_regex =
/\[translated: Join voice call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=true&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/;
assert.ok(called);
assert.match(syntax_to_insert, audio_link_regex);
})();
(function test_constructor_groups_video_link_compose_clicked() {
let syntax_to_insert;
let called = false;
const $textarea = $.create("constructor-groups-target-stub");
$textarea.set_parents_result(".message_edit_form", []);
const ev = {
preventDefault() {},
stopPropagation() {},
};
override(compose_ui, "insert_syntax_and_focus", (syntax) => {
syntax_to_insert = syntax;
called = true;
success_callback = undefined;
});
override(
realm,
"realm_video_chat_provider",
realm_available_video_chat_providers.constructor_groups.id,
);
let success_callback;
function call_success_callback() {
success_callback({
result: "success",
msg: "",
url: "https://example.constructor.app/groups/room/room-123",
});
}
channel.post = (payload) => {
assert.equal(payload.url, "/json/calls/constructorgroups/create");
assert.deepEqual(payload.data, {}); // Empty data object
success_callback = payload.success;
return {abort() {}};
};
$("textarea#compose-textarea").val("");
const video_handler = $("body").get_on_handler("click", ".video_link");
video_handler.call($textarea, ev);
call_success_callback();
const video_link_regex =
/\[translated: Join video call\.]\(https:\/\/example\.constructor\.app\/groups\/room\/room-123\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
})();
(function test_nextcloud_talk_audio_and_video_links_compose_clicked() {
let syntax_to_insert;
let called = false;
const $textarea = $.create("nextcloud-target-stub");
$textarea.set_parents_result(".message_edit_form", []);
const ev = {
preventDefault() {},
stopPropagation() {},
};
override(compose_ui, "insert_syntax_and_focus", (syntax) => {
syntax_to_insert = syntax;
called = true;
success_callback = undefined;
});
$("textarea#compose-textarea").val("");
override(
realm,
"realm_video_chat_provider",
realm_available_video_chat_providers.nextcloud_talk.id,
);
override(compose_closed_ui, "get_recipient_label", () => ({label_text: "general"}));
let success_callback;
function call_success_callback() {
assert.ok(success_callback !== undefined);
success_callback({
result: "success",
msg: "",
url: "https://nextcloud.example.com/index.php/call/abc123token",
});
}
const xhr_object = {abort() {}};
channel.post = (options) => {
assert.equal(options.url, "/json/calls/nextcloud_talk/create");
assert.equal(options.data.room_name, "general conversation");
success_callback = options.success;
return xhr_object;
};
$("textarea#compose-textarea").val("");
const video_handler = $("body").get_on_handler("click", ".video_link");
video_handler.call($textarea, ev);
call_success_callback();
const video_link_regex =
/\[translated: Join video call\.]\(https:\/\/nextcloud\.example\.com\/index\.php\/call\/abc123token\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
})();
});
test("test_video_chat_button_toggle disabled", ({override}) => {
override(realm, "realm_video_chat_provider", realm_available_video_chat_providers.disabled.id);
override(window, "to_$", () => $("window-stub"));
compose_setup.initialize();
assert.equal($(".compose-control-buttons-container .video_link").visible(), false);
});
test("test_video_chat_button_toggle no url", ({override}) => {
override(
realm,
"realm_video_chat_provider",
realm_available_video_chat_providers.jitsi_meet.id,
);
override(window, "to_$", () => $("window-stub"));
page_params.jitsi_server_url = null;
compose_setup.initialize();
assert.equal($(".compose-control-buttons-container .video_link").visible(), false);
});
test("test_video_chat_button_toggle enabled", ({override}) => {
override(
realm,
"realm_video_chat_provider",
realm_available_video_chat_providers.jitsi_meet.id,
);
override(realm, "realm_jitsi_server_url", "https://meet.jit.si");
override(window, "to_$", () => $("window-stub"));
compose_setup.initialize();
assert.equal($(".compose-control-buttons-container .video_link").visible(), true);
});
test("test_constructor_groups_video_chat_button_toggle enabled", ({override}) => {
override(
realm,
"realm_video_chat_provider",
realm_available_video_chat_providers.constructor_groups.id,
);
override(window, "to_$", () => $("window-stub"));
compose_setup.initialize();
assert.equal($(".compose-control-buttons-container .video_link").visible(), true);
});

View File

@@ -0,0 +1,82 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const compose_call_session = zrequire("compose_call_session");
run_test("ComposeCallSession", () => {
const session_manager = compose_call_session.compose_call_session_manager;
const session = session_manager.get_compose_call_session("key");
const session_returned = session_manager.get_compose_call_session("key");
assert.equal(session, session_returned);
const xhr1 = {};
const xhr2 = {};
session.append_pending_xhr(xhr1);
session.append_pending_xhr(xhr2);
let callback_ran = false;
const callback = () => {
callback_ran = true;
};
session.maybe_run_xhr_callback(xhr1, callback);
assert.ok(callback_ran);
/* istanbul ignore next */
session.maybe_run_xhr_callback(xhr1, () => {
throw new Error("XHR callback should only run once");
});
session.abort_pending_xhr(xhr2);
/* istanbul ignore next */
session.maybe_run_xhr_callback(xhr2, () => {
throw new Error("Callbacks shouldn't run for aborted XHRs.");
});
// Token callbacks for a provider.
let callback_ran_for_zoom_token1 = false;
let callback_ran_for_zoom_token2 = false;
const session1 = session_manager.get_compose_call_session("key1");
const session2 = session_manager.get_compose_call_session("key2");
const zoom_token_callback1 = () => {
callback_ran_for_zoom_token1 = true;
};
const zoom_token_callback2 = () => {
callback_ran_for_zoom_token2 = true;
};
session1.add_oauth_token_callback("zoom", zoom_token_callback1);
session2.add_oauth_token_callback("zoom", zoom_token_callback2);
session_manager.run_and_clear_callbacks_for_provider("zoom");
assert.ok(callback_ran_for_zoom_token1);
assert.ok(callback_ran_for_zoom_token2);
// All callbacks associated with a session be abandoned on calling
// ComposeCallSession.abandon_everything.
const session3 = session_manager.get_compose_call_session("key3");
const xhr_obj = {};
/* istanbul ignore next */
session3.add_oauth_token_callback("zoom", () => {
throw new Error("Token callbacks shouldn't run after abandon_everything");
});
/* istanbul ignore next */
session3.append_pending_xhr(xhr_obj, () => {
throw new Error("XHR callbacks shouldn't run after abandon_everything");
});
session_manager.abandon_session("key3");
session3.maybe_run_xhr_callback(xhr_obj);
session3.run_and_delete_callback_for_provider("zoom");
/* istanbul ignore next */
session3.maybe_run_xhr_callback(undefined, () => {
throw new Error("callbacks shouldn't run for undefined XHR objects");
});
});

View File

@@ -0,0 +1,372 @@
"use strict";
// Setup
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
// Mocking and stubbing things
set_global("document", "document-stub");
const message_lists = mock_esm("../src/message_lists");
const recent_view_util = mock_esm("../src/recent_view_util");
function MessageListView() {
return {
maybe_rerender: noop,
append: noop,
prepend: noop,
is_current_message_list: () => true,
};
}
mock_esm("../src/message_list_view", {
MessageListView,
});
mock_esm("../src/settings_data", {
user_can_access_all_other_users: () => true,
user_has_permission_for_group_setting: () => true,
});
const message_util = mock_esm("../src/message_util", {
user_can_send_direct_message: () => true,
});
const stream_data = zrequire("stream_data");
// Code we're actually using/testing
const compose_closed_ui = zrequire("compose_closed_ui");
const people = zrequire("people");
const {Filter} = zrequire("filter");
const {MessageList} = zrequire("message_list");
const {MessageListData} = zrequire("message_list_data");
const {set_current_user, set_realm} = zrequire("state_data");
const current_user = make_user({
email: "alice@zulip.com",
user_id: 1,
full_name: "Alice",
});
set_current_user(current_user);
people.add_active_user(current_user);
people.add_active_user(
make_user({
email: "bob@zulip.com",
user_id: 2,
full_name: "Bob",
}),
);
people.add_active_user(
make_user({
email: "zoe@zulip.com",
user_id: 3,
full_name: "Zoe",
}),
);
people.initialize_current_user(1);
const REALM_EMPTY_TOPIC_DISPLAY_NAME = "general chat";
set_realm(make_realm({realm_empty_topic_display_name: REALM_EMPTY_TOPIC_DISPLAY_NAME}));
// Helper test function
function test_reply_label(expected_label) {
assert.equal(
$("#left_bar_compose_reply_button_big")
.html()
.replace(/^(translated: )?Message /, ""),
expected_label,
);
}
run_test("reply_label", ({mock_template}) => {
mock_template(
"decorated_channel_name.hbs",
false,
(data) => `<rendered-channel-stub:${data.stream.name}>`,
);
// Mocking up a test message list
const filter = new Filter([]);
const list = new MessageList({
data: new MessageListData({
excludes_muted_topics: false,
filter,
}),
});
message_lists.current = list;
const stream_one = make_stream({
subscribed: true,
name: "first_stream",
stream_id: 1,
});
stream_data.add_sub_for_tests(stream_one);
const stream_two = make_stream({
subscribed: true,
name: "second_stream",
stream_id: 2,
});
stream_data.add_sub_for_tests(stream_two);
list.add_messages(
[
{
id: 0,
is_stream: true,
is_private: false,
stream_id: stream_one.stream_id,
topic: "first_topic",
sent_by_me: false,
sender_id: 2,
},
{
id: 1,
is_stream: true,
is_private: false,
stream_id: stream_one.stream_id,
topic: "second_topic",
sent_by_me: false,
sender_id: 2,
},
{
id: 2,
is_stream: true,
is_private: false,
stream_id: stream_two.stream_id,
topic: "third_topic",
sent_by_me: false,
sender_id: 2,
},
{
id: 3,
is_stream: true,
is_private: false,
stream_id: stream_two.stream_id,
topic: "second_topic",
sent_by_me: false,
sender_id: 2,
},
{
id: 4,
is_stream: false,
is_private: true,
to_user_ids: "2",
sent_by_me: false,
sender_id: 2,
},
{
id: 5,
is_stream: false,
is_private: true,
to_user_ids: "2,3",
sent_by_me: false,
sender_id: 2,
},
{
id: 6,
is_stream: true,
is_private: false,
stream_id: stream_two.stream_id,
topic: "",
sent_by_me: false,
sender_id: 2,
},
],
{},
true,
);
const expected_labels = [
"<rendered-channel-stub:first_stream> &gt; first_topic",
"<rendered-channel-stub:first_stream> &gt; second_topic",
"<rendered-channel-stub:second_stream> &gt; third_topic",
"<rendered-channel-stub:second_stream> &gt; second_topic",
"Bob",
"Bob, Zoe",
];
// Initialize the code we're testing.
compose_closed_ui.initialize();
// Run the tests!
let first = true;
for (const expected_label of expected_labels) {
if (first) {
list.select_id(list.first().id);
first = false;
} else {
list.select_id(list.next());
}
test_reply_label(expected_label);
}
// Separately test for empty string topic as the topic is specially decorated here.
list.select_id(list.next());
const label_html = $("#left_bar_compose_reply_button_big").html();
assert.equal(
label_html,
`translated: Message <rendered-channel-stub:second_stream> &gt; <span class="empty-topic-display">translated: ${REALM_EMPTY_TOPIC_DISPLAY_NAME}</span>`,
);
});
run_test("empty_narrow", () => {
message_lists.current.visibly_empty = () => true;
compose_closed_ui.update_reply_button_with_recipient_context();
const label = $("#left_bar_compose_reply_button_big").text();
assert.equal(label, "translated: Compose message");
});
run_test("test_non_message_list_input", ({mock_template}) => {
mock_template(
"decorated_channel_name.hbs",
false,
(data) => `<rendered-channel-stub:${data.stream.name}>`,
);
message_lists.current = undefined;
recent_view_util.is_visible = () => true;
const stream = make_stream({
subscribed: true,
name: "stream test",
stream_id: 10,
});
stream_data.add_sub_for_tests(stream);
// Channel and topic row.
compose_closed_ui.update_reply_button_with_recipient_context({
stream_id: stream.stream_id,
topic: "topic test",
});
test_reply_label("<rendered-channel-stub:stream test> &gt; topic test");
// Direct message conversation with current user row.
compose_closed_ui.update_reply_button_with_recipient_context({
user_ids: [current_user.user_id],
});
let label = $("#left_bar_compose_reply_button_big").html();
assert.equal(label, "translated: Write yourself a note");
// Invalid data for a the reply button text.
compose_closed_ui.update_reply_button_with_recipient_context({
invalid_field: "something unexpected",
});
label = $("#left_bar_compose_reply_button_big").text();
assert.equal(label, "translated: Compose message");
});
run_test("recipient_label_text_for_call_creation", () => {
recent_view_util.is_visible = () => true;
message_lists.current = undefined;
const stream = make_stream({
subscribed: true,
name: "stream test",
stream_id: 11,
});
stream_data.add_sub_for_tests(stream);
assert.equal(
compose_closed_ui.get_recipient_label({
stream_id: stream.stream_id,
topic: "topic test",
})?.label_text,
"#stream test > topic test",
);
// Empty topic falls back to the realm's empty-topic display name.
assert.equal(
compose_closed_ui.get_recipient_label({
stream_id: stream.stream_id,
topic: "",
})?.label_text,
`#stream test > translated: ${REALM_EMPTY_TOPIC_DISPLAY_NAME}`,
);
});
run_test("update_reply_button_state", ({override, override_rewire}) => {
const $compose_reply_wrapper = $("#legacy-closed-compose-box .compose-reply-button-wrapper");
const $reply_button = $(".compose_reply_button");
const postable_stream = make_stream({
subscribed: true,
name: "postable_stream",
stream_id: 20,
});
stream_data.add_sub_for_tests(postable_stream);
const restricted_stream = make_stream({
subscribed: true,
name: "restricted_stream",
stream_id: 21,
});
stream_data.add_sub_for_tests(restricted_stream);
override_rewire(
stream_data,
"can_post_messages_in_stream",
(stream) => stream.stream_id !== restricted_stream.stream_id,
);
// Reply button is enabled for stream where user can post.
message_lists.current = {};
$compose_reply_wrapper.attr("data-stream-id", postable_stream.stream_id.toString());
$compose_reply_wrapper.removeAttr("data-user-ids-string");
compose_closed_ui.update_reply_button_state();
assert.notEqual($reply_button.attr("disabled"), "disabled");
assert.equal($compose_reply_wrapper.attr("data-reply-button-type"), "selected_message");
// Reply button is disabled for stream where user cannot post.
$compose_reply_wrapper.attr("data-stream-id", restricted_stream.stream_id.toString());
compose_closed_ui.update_reply_button_state();
assert.equal($reply_button.attr("disabled"), "disabled");
assert.equal($compose_reply_wrapper.attr("data-reply-button-type"), "stream_disabled");
// Reply button is enabled for DM where user can send.
override(message_util, "user_can_send_direct_message", () => true);
$compose_reply_wrapper.removeAttr("data-stream-id");
$compose_reply_wrapper.attr("data-user-ids-string", "2");
compose_closed_ui.update_reply_button_state();
assert.notEqual($reply_button.attr("disabled"), "disabled");
assert.equal($compose_reply_wrapper.attr("data-reply-button-type"), "selected_message");
// Reply button is disabled for DM where user cannot send.
override(message_util, "user_can_send_direct_message", () => false);
compose_closed_ui.update_reply_button_state();
assert.equal($reply_button.attr("disabled"), "disabled");
assert.equal($compose_reply_wrapper.attr("data-reply-button-type"), "direct_disabled");
// No data attributes leads to enabled reply button.
message_lists.current = undefined;
$compose_reply_wrapper.removeAttr("data-stream-id");
$compose_reply_wrapper.removeAttr("data-user-ids-string");
compose_closed_ui.update_reply_button_state();
assert.notEqual($reply_button.attr("disabled"), "disabled");
// Button is not disabled for spectators.
page_params.is_spectator = true;
message_lists.current = {};
$compose_reply_wrapper.attr("data-stream-id", restricted_stream.stream_id.toString());
compose_closed_ui.update_reply_button_state();
assert.notEqual($reply_button.attr("disabled"), "disabled");
assert.equal($compose_reply_wrapper.attr("data-reply-button-type"), "selected_message");
page_params.is_spectator = false;
});
run_test("set_standard_text_resets_stale_state", ({override}) => {
const $compose_reply_wrapper = $("#legacy-closed-compose-box .compose-reply-button-wrapper");
const $reply_button = $(".compose_reply_button");
// Put the button in the "direct_disabled" state, with a recipient the
// user cannot message, so it carries a data-user-ids-string attribute.
override(message_util, "user_can_send_direct_message", () => false);
message_lists.current = {};
$compose_reply_wrapper.removeAttr("data-stream-id");
$compose_reply_wrapper.attr("data-user-ids-string", "2");
compose_closed_ui.update_reply_button_state();
assert.equal($reply_button.attr("disabled"), "disabled");
assert.equal($compose_reply_wrapper.attr("data-reply-button-type"), "direct_disabled");
// Resetting to the standard text must clear the recipient context along
// with the stale button type and disabled state, so the hover tooltip
// never sees "direct_disabled" without a data-user-ids-string attribute.
compose_closed_ui.set_standard_text_for_reply_button();
assert.equal($("#left_bar_compose_reply_button_big").text(), "translated: Compose message");
assert.equal($compose_reply_wrapper.attr("data-user-ids-string"), undefined);
assert.equal($compose_reply_wrapper.attr("data-reply-button-type"), undefined);
assert.notEqual($reply_button.attr("disabled"), "disabled");
});

View File

@@ -0,0 +1,143 @@
"use strict";
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const stream_data = zrequire("stream_data");
stream_data.set_channel_has_locally_available_topic(() => false);
const peer_data = zrequire("peer_data");
const people = zrequire("people");
const compose_fade = zrequire("compose_fade");
const compose_fade_helper = zrequire("compose_fade_helper");
const compose_state = zrequire("compose_state");
const {set_realm} = zrequire("state_data");
const realm = make_realm();
set_realm(realm);
const me = make_user({
email: "me@example.com",
user_id: 30,
full_name: "Me Myself",
});
const alice = make_user({
email: "alice@example.com",
user_id: 31,
full_name: "Alice",
});
const bob = make_user({
email: "bob@example.com",
user_id: 32,
full_name: "Bob",
});
people.add_active_user(me);
people.initialize_current_user(me.user_id);
people.add_active_user(alice);
people.add_active_user(bob);
run_test("set_focused_recipient", () => {
const sub = make_stream({
stream_id: 101,
name: "social",
subscribed: true,
});
stream_data.clear_subscriptions();
stream_data.add_sub_for_tests(sub);
compose_state.set_stream_id(sub.stream_id);
peer_data.set_subscribers(sub.stream_id, [me.user_id, alice.user_id]);
$("input#stream_message_recipient_topic").val("lunch");
compose_fade.set_focused_recipient("stream");
const good_msg = {
type: "stream",
stream_id: 101,
topic: "lunch",
};
const bad_msg = {
type: "stream",
stream_id: 999,
topic: "lunch",
};
assert.ok(!compose_fade_helper.should_fade_message(good_msg));
assert.ok(compose_fade_helper.should_fade_message(bad_msg));
});
run_test("want_normal_display", ({override, override_rewire}) => {
const stream_id = 110;
const sub = make_stream({
stream_id,
name: "display testing",
subscribed: true,
});
stream_data.clear_subscriptions();
// No focused recipient.
compose_fade_helper.set_focused_recipient(undefined);
assert.ok(compose_fade_helper.want_normal_display());
// Focused recipient is a sub that doesn't exist.
compose_fade_helper.set_focused_recipient({
type: "stream",
stream_id,
topic: "",
});
assert.ok(compose_fade_helper.want_normal_display());
// Focused recipient is a valid stream with no topic set
// when topics are mandatory
override(realm, "realm_topics_policy", "disable_empty_topic");
stream_data.add_sub_for_tests(sub);
assert.ok(compose_fade_helper.want_normal_display());
// Focused recipient is a valid stream with no topic set
// when topics are not mandatory.
override(realm, "realm_topics_policy", "allow_empty_topic");
override_rewire(stream_data, "can_create_new_topics_in_stream", () => true);
assert.ok(!compose_fade_helper.want_normal_display());
// When empty topics are allowed but user is still focused on
// the topic input, show normal display since user is still
// configuring topic.
$("input#stream_message_recipient_topic").trigger("focus");
assert.ok(compose_fade_helper.want_normal_display());
$("input#stream_message_recipient_topic").trigger("blur");
// When empty topics are allowed by policy but the user cannot
// create new topics and no empty topic exists, show normal
// display since the compose target is incomplete.
override_rewire(stream_data, "can_create_new_topics_in_stream", () => false);
assert.ok(compose_fade_helper.want_normal_display());
// If we're focused to a topic, then we do want to fade.
compose_fade_helper.set_focused_recipient({
type: "stream",
stream_id,
topic: "lunch",
});
assert.ok(!compose_fade_helper.want_normal_display());
// Private message with no recipient.
compose_fade_helper.set_focused_recipient({
type: "private",
});
assert.ok(compose_fade_helper.want_normal_display());
// Private message with a recipient.
compose_fade_helper.set_focused_recipient({
type: "private",
to_user_ids: "31",
});
assert.ok(!compose_fade_helper.want_normal_display());
});

View File

@@ -0,0 +1,844 @@
"use strict";
const assert = require("node:assert/strict");
const {JSDOM} = require("jsdom");
const katex_tests = require("../../zerver/tests/fixtures/katex_test_cases.json");
const {parse} = require("../src/markdown.ts");
const {make_user_group} = require("./lib/example_group.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {mock_esm, zrequire, set_global} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const {window} = new JSDOM();
const text_field_edit = mock_esm("text-field-edit", {insertTextIntoField: noop});
const compose_paste = zrequire("compose_paste");
const compose_ui = zrequire("compose_ui");
const linkifiers = zrequire("linkifiers");
const markdown = zrequire("markdown");
const markdown_config = zrequire("markdown_config");
const people = zrequire("people");
const stream_data = zrequire("stream_data");
const user_groups = zrequire("user_groups");
const {initialize_user_settings} = zrequire("user_settings");
set_global("document", {});
class ClipboardEvent {
constructor({clipboardData} = {}) {
this.clipboardData = clipboardData;
}
}
set_global("ClipboardEvent", ClipboardEvent);
initialize_user_settings({
user_settings: {
translate_emoticons: false,
},
});
markdown.initialize(markdown_config.get_helpers());
// User and group ids match the ids used in the mention test fixtures
// below.
const king_hamlet = make_user({user_id: 10, full_name: "King Hamlet"});
const desdemona = make_user({user_id: 9, full_name: "Desdemona"});
const polonius = make_user({user_id: 13, full_name: "Polonius"});
people.add_active_user(king_hamlet);
people.add_active_user(desdemona);
people.add_active_user(polonius);
const hamletcharacters = make_user_group({name: "hamletcharacters", id: 24});
const owners = make_user_group({name: "role:owners", id: 25, is_system_group: true});
user_groups.initialize({realm_user_groups: [hamletcharacters, owners]});
stream_data.add_sub_for_tests(
make_stream({
stream_id: 4,
name: "Rome",
}),
);
stream_data.add_sub_for_tests(
make_stream({
stream_id: 5,
name: "Romeo`s lair",
}),
);
run_test("try_stream_topic_syntax_text", () => {
const test_cases = [
[
"http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT",
"#**Rome>old FAILED EXPORT**",
],
[
"http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/100.25.20profits",
"#**Rome>100% profits**",
],
[
"http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20API.20wasn't.20compiling.20erratically",
"#**Rome>old API wasn't compiling erratically**",
],
["http://different.origin.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT"],
[
"http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT/near/100",
"#**Rome>old FAILED EXPORT@100**",
],
["http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic//near/100", "#**Rome>@100**"],
[
"http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT/with/100",
"#**Rome>old FAILED EXPORT**",
],
// malformed urls
["http://zulip.zulipdev.com/narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#not_narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#narrow/not_stream/4-Rome/topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#narrow/channel/4-Rome/not_topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#narrow/channel/4-Rome/", "#**Rome**"],
["http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic"],
["http://zulip.zulipdev.com/#narrow/topic/cheese"],
["http://zulip.zulipdev.com/#narrow/topic/pizza/stream/Rome"],
["http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT/near/"],
// When a url containing characters which are known to produce broken
// #**stream>topic** urls is pasted, a normal markdown link syntax is produced.
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20profits.60",
"[#Rome > 100% profits&#96;](#narrow/channel/4-Rome/topic/100.25.20profits.60)",
],
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20*profits",
"[#Rome > 100% &#42;profits](#narrow/channel/4-Rome/topic/100.25.20.2Aprofits)",
],
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/.24.24 100.25.20profits",
"[#Rome > &#36;&#36; 100% profits](#narrow/channel/4-Rome/topic/.24.24.20100.25.20profits)",
],
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/>100.25.20profits",
"[#Rome > &gt;100% profits](#narrow/channel/4-Rome/topic/.3E100.25.20profits)",
],
[
"http://zulip.zulipdev.com/#narrow/stream/5-Romeo.60s-lair/topic/normal",
"[#Romeo&#96;s lair > normal](#narrow/channel/5-Romeo.60s-lair/topic/normal)",
],
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20profits.60/near/20",
"[#Rome > 100% profits&#96; @ 💬](#narrow/channel/4-Rome/topic/100.25.20profits.60/near/20)",
],
];
for (const test_case of test_cases) {
const result = compose_paste.try_stream_topic_syntax_text(test_case[0]);
const expected = test_case[1] ?? null;
assert.equal(result, expected, "Failed for url: " + test_case[0]);
}
});
run_test("maybe_transform_html", () => {
// Copied HTML from VS Code
let paste_html = `<div style="color: #cccccc;background-color: #1f1f1f;font-family: 'Droid Sans Mono', 'monospace', monospace;font-weight: normal;font-size: 14px;line-height: 19px;white-space: pre;"><div><span style="color: #c586c0;">if</span><span style="color: #cccccc;"> (</span><span style="color: #9cdcfe;">$preview_src</span><span style="color: #cccccc;">.</span><span style="color: #dcdcaa;">endsWith</span><span style="color: #cccccc;">(</span><span style="color: #ce9178;">"&amp;size=full"</span><span style="color: #cccccc;">))</span></div></div>`;
let paste_text = `if ($preview_src.endsWith("&size=full"))`;
const escaped_paste_text = "if ($preview_src.endsWith(&quot;&amp;size=full&quot;))";
const expected_output = "<pre><code>" + escaped_paste_text + "</code></pre>";
assert.equal(compose_paste.maybe_transform_html(paste_html, paste_text), expected_output);
// Untransformed HTML
paste_html = "<div><div>Hello</div><div>World!</div></div>";
paste_text = "Hello\nWorld!";
assert.equal(compose_paste.maybe_transform_html(paste_html, paste_text), paste_html);
});
run_test("paste_handler reverse linkify", ({override, override_rewire}) => {
global.document = window.document;
global.window = window;
global.Node = window.Node;
global.HTMLElement = window.HTMLElement;
global.HTMLAnchorElement = window.HTMLAnchorElement;
global.HTMLTextAreaElement = window.HTMLTextAreaElement;
linkifiers.update_linkifier_rules([
{
id: 1,
pattern: "#D(?P<id>[0-9]{2,8})",
url_template: "https://github.com/zulip/zulip-desktop/pull/{id}",
reverse_template: "#D{id}",
alternative_url_templates: ["https://github.com/zulip/zulip-desktop/issues/{id}"],
},
]);
let inserted_text;
let undo_texts;
override_rewire(compose_ui, "insert_and_scroll_into_view", (text) => {
inserted_text = text;
});
override(text_field_edit, "insertTextIntoField", (_textarea, text) => {
undo_texts.push(text);
});
const html_with_formatting = `
<p>
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>x</mi>
<mo>+</mo>
<mi>y</mi>
<mn>2</mn>
</mrow>
<annotation encoding="application/x-tex">x + y2</annotation>
</semantics>
</math>
</span>
<span class="katex-html" aria-hidden="true">
<span class="base">
<span class="mord mathnormal">x</span>
<span class="mbin">+</span>
</span>
<span class="base">
<span class="mord mathnormal">y</span>
<span class="mord">2</span>
</span>
</span>
</span>
<del>test</del>
<a href="https://github.com/zulip/zulip-desktop/pull/1359">https://github.com/zulip/zulip-desktop/pull/1359</a>
</p>`;
const test_cases = [
{
// Reverse linkify should preserve formatting when pasting HTML.
paste_html: html_with_formatting,
paste_text:
"x\n+\ny\n2\nx + y2\nx+y2 test https://github.com/zulip/zulip-desktop/pull/1359",
expected: "$$x + y2$$ ~~test~~ #D1359",
// When paste_html is a real value, there are two undo states:
// first undo reverses the reverse linkify (back to formatted text),
// second undo reverses the formatting (back to plain text).
expected_undo_texts: [
"x\n+\ny\n2\nx + y2\nx+y2 test https://github.com/zulip/zulip-desktop/pull/1359",
"$$x + y2$$ ~~test~~ https://github.com/zulip/zulip-desktop/pull/1359",
],
},
{
// Reverse linkify should work for URL only text.
paste_html: "",
paste_text: "https://github.com/zulip/zulip-desktop/pull/1359",
expected: "#D1359",
expected_undo_texts: ["https://github.com/zulip/zulip-desktop/pull/1359"],
},
{
// Reverse linkify should work in plain text with surrounding text.
paste_html: "",
paste_text: "https://github.com/zulip/zulip-desktop/pull/1359 dummy text.",
expected: "#D1359 dummy text.",
expected_undo_texts: ["https://github.com/zulip/zulip-desktop/pull/1359 dummy text."],
},
{
// Reverse linkify should work for alternative URL templates.
paste_html: "",
paste_text: "https://github.com/zulip/zulip-desktop/issues/42",
expected: "#D42",
expected_undo_texts: ["https://github.com/zulip/zulip-desktop/issues/42"],
},
];
for (const test_case of test_cases) {
const $textarea = $("textarea#compose-textarea");
// Put the cursor at the start with no selected text.
// The URL paste path checks this before it reaches reverse-linkify.
$textarea[0] = window.document.createElement("textarea");
$textarea[0].value = "";
inserted_text = undefined;
undo_texts = [];
const event = {
originalEvent: new ClipboardEvent({
clipboardData: {
getData(format) {
if (format === "text/html") {
return test_case.paste_html;
}
return test_case.paste_text;
},
},
}),
preventDefault() {},
stopPropagation() {},
};
compose_paste.paste_handler.call($textarea, event, noop);
assert.equal(inserted_text, test_case.expected, test_case.paste_text);
assert.deepEqual(
undo_texts,
test_case.expected_undo_texts,
`undo texts for: ${test_case.paste_text}`,
);
}
});
run_test("paste_handler_converter", () => {
/*
Pasting from another Zulip message
*/
global.document = window.document;
global.window = window;
global.Node = window.Node;
global.HTMLElement = window.HTMLElement;
global.HTMLAnchorElement = window.HTMLAnchorElement;
// Bold text
let input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><span style="color: hsl(0, 0%, 13%); font-family: arial, sans-serif; font-size: 12.8px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;"><span> </span>love the<span> </span><b>Zulip</b><b> </b></span><b style="color: hsl(0, 0%, 13%); font-family: arial, sans-serif; font-size: 12.8px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">Organization</b><span style="color: hsl(0, 0%, 13%); font-family: arial, sans-serif; font-size: 12.8px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">.</span>';
assert.equal(
compose_paste.paste_handler_converter(input),
" love the **Zulip** **Organization**.",
);
// Inline code
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><span style="color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">The<span> </span></span><code style="box-sizing: border-box; font-family: SFMono-Regular, Consolas, &quot;Liberation Mono&quot;, Menlo, Courier, monospace; font-size: 13.6px; padding: 0.2em 0.4em; margin: 0px; background-color: hsla(210, 13%, 12%, 0.05); border-radius: 3px; color: hsl(210, 12%, 16%); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">JSDOM</code><span style="color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"><span> </span>constructor</span>';
assert.equal(compose_paste.paste_handler_converter(input), "The `JSDOM` constructor");
// A python code block
input = `<meta http-equiv="content-type" content="text/html; charset=utf-8"><p>zulip code block in python</p><div class="codehilite zulip-code-block" data-code-language="Python"><pre><span></span><code><span class="nb">print</span><span class="p">(</span><span class="s2">"hello"</span><span class="p">)</span>\n<span class="nb">print</span><span class="p">(</span><span class="s2">"world"</span><span class="p">)</span></code></pre></div></meta>`;
assert.equal(
compose_paste.paste_handler_converter(input),
'zulip code block in python\n\n```Python\nprint("hello")\nprint("world")\n```',
);
// Single line in a code block
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(compose_paste.paste_handler_converter(input), "`single line`");
// No code formatting if the given text area has a backtick at the cursor position
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
compose_paste.paste_handler_converter(input, {
val() {
return "e.g. `";
},
0: {
selectionStart: 6,
value: "e.g. `",
},
length: 1,
}),
"single line",
);
// No code formatting if the given text area has a backtick at the cursor position
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
compose_paste.paste_handler_converter(input, {
val() {
return "`e.g. ` ```hi`` ``";
},
0: {
selectionStart: 19,
value: "`e.g. ` ```hi`` ``",
},
length: 1,
}),
"single line",
);
// No code formatting if the given text area has a opening backtick before the cursor position
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
compose_paste.paste_handler_converter(input, {
val() {
return "e.g. ` ";
},
0: {
selectionStart: 7,
value: "e.g. ` ",
},
length: 1,
}),
"single line",
);
// Yes code formatting if the given text area does not have a backtick at the cursor position.
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
compose_paste.paste_handler_converter(input, {
val() {
return "";
},
0: {
selectionStart: 0,
value: "",
},
length: 1,
}),
"`single line`",
);
// Yes code formatting if the given text area closes the code block before the cursor position
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
compose_paste.paste_handler_converter(input, {
val() {
return "` e.g. ` ";
},
0: {
selectionStart: 9,
value: "` e.g. ` ",
},
length: 1,
}),
"`single line`",
);
// Yes code formatting if the given text area closes the code block before the cursor position
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><pre><code>single line</code></pre>';
assert.equal(
compose_paste.paste_handler_converter(input, {
val() {
return "``` e.g. ``` ``hi`` ";
},
0: {
selectionStart: 20,
value: "``` e.g. ``` ``hi`` ",
},
length: 1,
}),
"`single line`",
);
// Raw links without custom text
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><a href="https://zulip.readthedocs.io/en/latest/subsystems/logging.html" target="_blank" title="https://zulip.readthedocs.io/en/latest/subsystems/logging.html" style="color: hsl(200, 100%, 40%); text-decoration: none; cursor: pointer; font-family: &quot;Source Sans 3&quot;, &quot;Helvetica Neue&quot;, Helvetica, Arial, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%);">https://zulip.readthedocs.io/en/latest/subsystems/logging.html</a>';
assert.equal(
compose_paste.paste_handler_converter(input),
"https://zulip.readthedocs.io/en/latest/subsystems/logging.html",
);
// Links with custom text
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><a class="reference external" href="https://zulip.readthedocs.io/en/latest/contributing/contributing.html" style="box-sizing: border-box; color: hsl(283, 39%, 53%); text-decoration: none; cursor: pointer; outline: 0px; font-family: Lato, proxima-nova, &quot;Helvetica Neue&quot;, Arial, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 99%);">Contributing guide</a>';
assert.equal(
compose_paste.paste_handler_converter(input),
"[Contributing guide](https://zulip.readthedocs.io/en/latest/contributing/contributing.html)",
);
// Only numbered list (list style retained)
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><ol><li>text</li></ol>';
assert.equal(compose_paste.paste_handler_converter(input), "1. text");
// Heading
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><h1 style="box-sizing: border-box; font-size: 2em; margin-top: 0px !important; margin-right: 0px; margin-bottom: 16px; margin-left: 0px; font-weight: 600; line-height: 1.25; padding-bottom: 0.3em; border-bottom: 1px solid hsl(216, 14%, 93%); color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">Zulip overview</h1><p>normal text</p>';
assert.equal(compose_paste.paste_handler_converter(input), "# Zulip overview\n\nnormal text");
// Only heading (strip heading style)
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><h1 style="box-sizing: border-box; font-size: 2em; margin-top: 0px !important; margin-right: 0px; margin-bottom: 16px; margin-left: 0px; font-weight: 600; line-height: 1.25; padding-bottom: 0.3em; border-bottom: 1px solid hsl(216, 14%, 93%); color: hsl(210, 12%, 16%); font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">Zulip overview</h1>';
assert.equal(compose_paste.paste_handler_converter(input), "Zulip overview");
// Italic text
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8">normal text <i style="box-sizing: inherit; color: hsl(0, 0%, 0%); font-family: Verdana, sans-serif; font-size: 15px; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">This text is italic</i>';
assert.equal(compose_paste.paste_handler_converter(input), "normal text *This text is italic*");
// Strikethrough text
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8">normal text <del style="box-sizing: inherit; color: hsl(0, 0%, 0%); font-family: Verdana, sans-serif; font-size: 15px; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: hsl(0, 0%, 100%); text-decoration-style: initial; text-decoration-color: initial;">This text is struck through</del>';
assert.equal(
compose_paste.paste_handler_converter(input),
"normal text ~~This text is struck through~~",
);
// Emojis
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><span style="color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">emojis:<span> </span></span><span aria-label="smile" class="emoji emoji-1f604" role="img" title="smile" style="height: 20px; width: 20px; position: relative; margin-top: -7px; vertical-align: middle; top: 3px; background-position: 55% 46.667%; display: inline-block; background-image: url(&quot;http://localhost:9991/webpack/files/generated/emoji/google.webp&quot;); background-size: 6100%; background-repeat: no-repeat; text-indent: 100%; white-space: nowrap; overflow: hidden; color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">:smile:</span><span style="color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"><span> </span></span><span aria-label="family man woman girl" class="emoji emoji-1f468-200d-1f469-200d-1f467" role="img" title="family man woman girl" style="height: 20px; width: 20px; position: relative; margin-top: -7px; vertical-align: middle; top: 3px; background-position: 23.333% 75%; display: inline-block; background-image: url(&quot;http://localhost:9991/webpack/files/generated/emoji/google.webp&quot;); background-size: 6100%; background-repeat: no-repeat; text-indent: 100%; white-space: nowrap; overflow: hidden; color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">:family_man_woman_girl:</span>';
assert.equal(
compose_paste.paste_handler_converter(input),
"emojis: :smile: :family_man_woman_girl:",
);
// Nested lists
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><ul style="padding: 0px; margin: 0px 0px 5px 20px; color: rgb(221, 222, 238); font-family: &quot;Source Sans 3&quot;, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(33, 45, 59); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><li style="line-height: inherit;">bulleted</li><li style="line-height: inherit;">nested<ul style="padding: 0px; margin: 2px 0px 5px 20px;"><li style="line-height: inherit;">nested level 1</li><li style="line-height: inherit;">nested level 1 continue<ul style="padding: 0px; margin: 2px 0px 5px 20px;"><li style="line-height: inherit;">nested level 2</li><li style="line-height: inherit;">nested level 2 continue</li></ul></li></ul></li></ul>';
assert.equal(
compose_paste.paste_handler_converter(input),
"* bulleted\n* nested\n * nested level 1\n * nested level 1 continue\n * nested level 2\n * nested level 2 continue",
);
// Heading from https://arxiv.org/abs/1301.3191
input =
'<html><body><!--StartFragment--><h1 class="title mathjax" style="font-size: 1.8em !important; font-weight: 700; line-height: 27.9936px; display: block; margin-block: 12px; margin: 0.25em 0px 12px 20px; margin-inline: 20px 0px; color: rgb(0, 0, 0); font-family: &quot;Lucida Grande&quot;, Helvetica, Arial, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">Enriched categories as a free cocompletion</h1><br class="Apple-interchange-newline"><!--EndFragment--></body></html>';
assert.equal(
compose_paste.paste_handler_converter(input),
"Enriched categories as a free cocompletion",
);
// Heading from https://www.sciencedirect.com/science/article/pii/S0001870815004715
input =
'<html><body><!--StartFragment--><h2 class="section-title u-h4 u-margin-l-top u-margin-xs-bottom" style="box-sizing: border-box; margin-top: 32px !important; margin-right: 0px; margin-bottom: 8px !important; margin-left: 0px; padding: 0px; font-weight: normal !important; font-size: 20px !important; line-height: var(--sd-ui-line-height) !important; color: rgb(31, 31, 31); font-family: ElsevierGulliver, Georgia, "Times New Roman", Times, STIXGeneral, "Cambria Math", "Lucida Sans Unicode", "Microsoft Sans Serif", "Segoe UI Symbol", "Arial Unicode MS", serif, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">Abstract</h2><div id="as0010" style="box-sizing: border-box; margin: 0px; padding: 0px; color: rgb(31, 31, 31); font-family: ElsevierGulliver, Georgia, "Times New Roman", Times, STIXGeneral, "Cambria Math", "Lucida Sans Unicode", "Microsoft Sans Serif", "Segoe UI Symbol", "Arial Unicode MS", serif, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><br class="Apple-interchange-newline"><!--EndFragment--></body></html>';
assert.equal(compose_paste.paste_handler_converter(input), "Abstract");
// Heading from https://en.wikipedia.org/wiki/James_Madison
input =
'<html><body><!--StartFragment--><h1 id="firstHeading" class="firstHeading mw-first-heading" style="color: var(--color-emphasized,#101418); font-weight: normal; margin: 0px; padding: 0px; display: flow-root; word-break: break-word; border: 0px; font-size: 1.8em; font-family: &quot;Linux Libertine&quot;, Georgia, Times, &quot;Source Serif Pro&quot;, serif; line-height: 1.375; overflow-wrap: break-word; flex-grow: 1; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mw-page-title-main">James Madison</span></h1><div id="p-lang-btn" class="vector-dropdown mw-portlet mw-portlet-lang" style="position: relative; float: right; box-sizing: border-box; flex-shrink: 0; margin-right: -12px; color: rgb(32, 33, 34); font-family: sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"></div><br class="Apple-interchange-newline"><!--EndFragment--></body></html>';
assert.equal(compose_paste.paste_handler_converter(input), "James Madison");
// Heading from https://customer-identity-access-management.hashnode.dev/from-words-to-vectors-understanding-the-magic-of-text-embedding
input = `<html><body><!--StartFragment--><div class="mt-6 break-words px-4 text-center font-heading text-3xl font-bold text-slate-900 dark:text-white md:mt-10 md:px-5 md:text-4xl lg:px-8 xl:px-20 xl:text-5xl mb-8 md:mb-14" style="box-sizing: border-box; ...">
<h1 class="leading-tight" data-query="post-title">From Words to Vectors: Understanding the Magic of Text Embedding</h1>
</div></body></html>`;
assert.equal(
compose_paste.paste_handler_converter(input),
"From Words to Vectors: Understanding the Magic of Text Embedding",
);
// Check we don't double-convert HTML to text.
input = `<del>turtles are cool</del>`;
assert.equal(compose_paste.paste_handler_converter(input), "turtles are cool");
input = `<span style="color: rgb(31, 35, 40); font-family: ui-monospace, SFMono-Regular, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, monospace; font-size: 11.9px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: break-spaces; background-color: rgba(129, 139, 152, 0.12); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">&lt;del&gt;turtles are cool&lt;/del&gt;</span>`;
assert.equal(compose_paste.paste_handler_converter(input), "<del>turtles are cool</del>");
// 2 paragraphs with line break/s in between
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><p>paragraph 1</p><br><p>paragraph 2</p>';
assert.equal(compose_paste.paste_handler_converter(input), "paragraph 1\n\nparagraph 2");
// Pasting from external sources
// Pasting list from GitHub
input =
'<div class="preview-content"><div class="comment"><div class="comment-body markdown-body js-preview-body" style="min-height: 131px;"><p>Test list:</p><ul><li>Item 1</li><li>Item 2</li></ul></div></div></div>';
assert.equal(compose_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2");
// Pasting list from VS Code
input =
'<div class="ace-line gutter-author-d-iz88z86z86za0dz67zz78zz78zz74zz68zjz80zz71z9iz90za3z66zs0z65zz65zq8z75zlaz81zcz66zj6g2mz78zz76zmz66z22z75zfcz69zz66z ace-ltr focused-line" dir="auto" id="editor-3-ace-line-41"><span>Test list:</span></div><div class="ace-line gutter-author-d-iz88z86z86za0dz67zz78zz78zz74zz68zjz80zz71z9iz90za3z66zs0z65zz65zq8z75zlaz81zcz66zj6g2mz78zz76zmz66z22z75zfcz69zz66z line-list-type-bullet ace-ltr" dir="auto" id="editor-3-ace-line-42"><ul class="listtype-bullet listindent1 list-bullet1"><li><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="end"></span><span class="ace-line-pocket" data-faketext="" contenteditable="false"></span><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="start"></span><span>Item 1</span></li></ul></div><div class="ace-line gutter-author-d-iz88z86z86za0dz67zz78zz78zz74zz68zjz80zz71z9iz90za3z66zs0z65zz65zq8z75zlaz81zcz66zj6g2mz78zz76zmz66z22z75zfcz69zz66z line-list-type-bullet ace-ltr" dir="auto" id="editor-3-ace-line-43"><ul class="listtype-bullet listindent1 list-bullet1"><li><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="end"></span><span class="ace-line-pocket" data-faketext="" contenteditable="false"></span><span class="ace-line-pocket-zws" data-faketext="" data-contentcollector-ignore-space-at="start"></span><span>Item 2</span></li></ul></div>';
assert.equal(compose_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2");
// Pasting markdown from VS Code where each line is wrapped by a <div> shouldn't insert an extra newline.
input = `<html>\r\n<body>\r\n<!--StartFragment--><div style="color: #cccccc;background-color: #1f1f1f;font-family: 'Fira Code Retina', Consolas, 'Courier New', monospace;font-weight: 350;font-size: 12.852px;line-height: 17px;white-space: pre;"><div><span style="color: #cccccc;">authentication-methods</span></div><div><span style="color: #cccccc;">export-and-import</span></div><div><span style="color: #cccccc;">postgresql</span></div><div><span style="color: #cccccc;">upload-backends</span></div><div><span style="color: #cccccc;">ssl-certificates</span></div><div><span style="color: #cccccc;">email</span></div></div><!--EndFragment-->\r\n</body>\r\n</html>`;
assert.equal(
compose_paste.paste_handler_converter(input),
"authentication-methods\nexport-and-import\npostgresql\nupload-backends\nssl-certificates\nemail",
);
// Pasting from Google Sheets (remove <style> elements completely)
input =
'<meta http-equiv="content-type" content="text/html; charset=utf-8"><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><span style="font-size:10pt;font-family:Arial;font-style:normal;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:123}" data-sheets-userformat="{&quot;2&quot;:769,&quot;3&quot;:{&quot;1&quot;:0},&quot;11&quot;:3,&quot;12&quot;:0}">123</span>';
assert.equal(compose_paste.paste_handler_converter(input), "123");
// Pasting a long, visually line-wrapped single-line message from Firefox should not insert extraneous newlines.
input = `<html><body>\n<!--StartFragment--><div class="message_content rendered_markdown">\n<p>At some point recently, Zulip changed such that copying a \nlong message includes hard newlines, rather than putting things all on \none line when they were on one line in the original message.</p>\n</div><!--EndFragment-->\n</body>\n</html>`;
assert.equal(
compose_paste.paste_handler_converter(input),
"At some point recently, Zulip changed such that copying a long message includes hard newlines, rather than putting things all on one line when they were on one line in the original message.",
);
// Pasting from Excel
input = `<html xmlns:v="urn:schemas-microsoft-com:vml"\nxmlns:o="urn:schemas-microsoft-com:office:office"\nxmlns:x="urn:schemas-microsoft-com:office:excel"\nxmlns="http://www.w3.org/TR/REC-html40">\n<head>\n<meta http-equiv=Content-Type content="text/html; charset=utf-8">\n<meta name=ProgId content=Excel.Sheet>\n<meta name=Generator content="Microsoft Excel 15">\n<link id=Main-File rel=Main-File\nhref="file:///C:/Users/ADMINI~1/AppData/Local/Temp/msohtmlclip1/01/clip.htm">\n<link rel=File-List\nhref="file:///C:/Users/ADMINI~1/AppData/Local/Temp/msohtmlclip1/01/clip_filelist.xml">\n<style>\n<!--table\n {mso-displayed-decimal-separator:"\\.";\n mso-displayed-thousand-separator:"\\,";}\n@page\n {margin:.75in .7in .75in .7in;\n mso-header-margin:.3in;\n mso-footer-margin:.3in;}\ntr\n {mso-height-source:auto;}\ncol\n {mso-width-source:auto;}\nbr\n {mso-data-placement:same-cell;}\ntd\n {padding-top:1px;\n padding-right:1px;\n padding-left:1px;\n mso-ignore:padding;\n color:black;\n font-size:11.0pt;\n font-weight:400;\n font-style:normal;\n text-decoration:none;\n font-family:Calibri, sans-serif;\n mso-font-charset:0;\n mso-number-format:General;\n text-align:general;\n vertical-align:bottom;\n border:none;\n mso-background-source:auto;\n mso-pattern:auto;\n mso-protection:locked visible;\n white-space:nowrap;\n mso-rotate:0;}\n.xl65\n {mso-number-format:"_\\(\\0022$\\0022* \\#\\,\\#\\#0\\.00_\\)\\;_\\(\\0022$\\0022* \\\\\\(\\#\\,\\#\\#0\\.00\\\\\\)\\;_\\(\\0022$\\0022* \\0022-\\0022??_\\)\\;_\\(\\@_\\)";}\n-->\n</style>\n</head>\n<body link="#0563C1" vlink="#954F72">\n<table border=0 cellpadding=0 cellspacing=0 width=88 style='border-collapse:\n collapse;width:66pt'>\n<!--StartFragment-->\n <col width=88 style='mso-width-source:userset;mso-width-alt:3218;width:66pt'>\n <tr height=20 style='height:15.0pt'>\n <td height=20 class=xl65 width=88 style='height:15.0pt;width:66pt;font-size:\n 11.0pt;color:black;font-weight:400;text-decoration:none;text-underline-style:\n none;text-line-through:none;font-family:Calibri, sans-serif;border-top:.5pt solid #5B9BD5;\n border-right:none;border-bottom:none;border-left:none'><span\n style='mso-spacerun:yes'> </span>$<span style='mso-spacerun:yes'>\n </span>20.00 </td>\n </tr>\n <tr height=20 style='height:15.0pt'>\n <td height=20 class=xl65 style='height:15.0pt;font-size:11.0pt;color:black;\n font-weight:400;text-decoration:none;text-underline-style:none;text-line-through:\n none;font-family:Calibri, sans-serif;border-top:.5pt solid #5B9BD5;\n border-right:none;border-bottom:none;border-left:none'><span\n style='mso-spacerun:yes'> </span>$<span\n style='mso-spacerun:yes'> </span>7.00 </td>\n </tr>\n<!--EndFragment-->\n</table>\n</body>\n</html>`;
// Pasting from Excel using ^V should paste an image.
assert.ok(compose_paste.is_single_image(input));
// Pasting from Excel using ^⇧V should paste formatted text.
assert.equal(compose_paste.paste_handler_converter(input), " \n\n$ 20.00\n\n$ 7.00");
// Pasting from LibreOffice Calc should paste an image.
input = `<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"><title></title><meta name="generator" content="LibreOffice 25.2.3.2 (Windows)"><style type="text/css"> body,div,table,thead,tbody,tfoot,tr,th,td,p { font-family:"Arial"; font-size:x-small } a.comment-indicator:hover + comment { background:#ffd; position:absolute; display:block; border:1px solid black; padding:0.5em; } a.comment-indicator { background:red; display:inline-block; border:1px solid black; width:0.5em; height:0.5em; } comment { display:none; } </style></head><body><table cellspacing="0" border="0"><colgroup span="4" width="107"></colgroup><tbody><tr><td height="24" align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;Kathleen&quot;}">Kathleen</td><td align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;Hanner&quot;}">Hanner</td><td align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;Female&quot;}">Female</td><td align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;United States&quot;}">United States</td></tr><tr><td height="24" align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;Nereida&quot;}">Nereida</td><td align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;Magwood&quot;}">Magwood</td><td align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;Female&quot;}">Female</td><td align="left" data-sheets-value="{ &quot;1&quot;: 2, &quot;2&quot;: &quot;United States&quot;}">United States</td></tr></tbody></table></body></html>`;
assert.ok(compose_paste.is_single_image(input));
// Pastes containing the `data-message-header-paragraph="true"` attribute should
// be formatted as per the custom turndown rule. The empty paragraph that
// copy_messages inserts between consecutive messages from the same recipient
// should render as a blank line separating them.
input = `<html><body><!--StartFragment--><p data-message-header-paragraph="true"><strong>test &gt; LAST API has been skipping erratically </strong><span>| Dec 20</span></p><b>Othello, the Moor of Venice: </b><div>When shall we three meet again In thunder, lightning, or in rain?</div><p></p><b>Othello, the Moor of Venice: </b><div>The second consecutive message.</div><p data-message-header-paragraph="true"><strong>test &gt; leased keurig wasn't loading </strong><span>| Dec 21</span></p><b>King Hamlet: </b><div>Sit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen.</div><!--EndFragment--></body></html>`;
assert.equal(
compose_paste.paste_handler_converter(input),
"**test > LAST API has been skipping erratically** | Dec 20\n**Othello, the Moor of Venice:**\nWhen shall we three meet again In thunder, lightning, or in rain?\n\n**Othello, the Moor of Venice:**\nThe second consecutive message.\n\n**test > leased keurig wasn't loading** | Dec 21\n**King Hamlet:**\nSit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen.",
);
// Copying a message that is a paragraph followed by a list: copy_messages
// unwraps only the leading paragraph into a div (so the sender name is
// separated from the body by a single newline), leaving the list intact.
// The list must stay on its own line and not get glued onto the paragraph.
input = `<html><body><!--StartFragment--><b>Desdemona: </b><div>intro text</div>\n<ul>\n<li>item one</li>\n<li>item two</li>\n<li>item three</li>\n</ul><!--EndFragment--></body></html>`;
assert.equal(
compose_paste.paste_handler_converter(input),
"**Desdemona:**\nintro text\n* item one\n* item two\n* item three",
);
// Partially selecting the first message inserts the "..." ellipsis inside
// the leading paragraph, so it stays glued to the start of the truncated
// text (on the same line) rather than landing on its own line.
input = `<html><body><!--StartFragment--><b>Iago: </b><div><span>...</span>of Nephelococcygia</div>\n<ul>\n<li>Myrmica ruginodis</li>\n<li>Myrmica kotokui</li>\n</ul><!--EndFragment--></body></html>`;
assert.equal(
compose_paste.paste_handler_converter(input),
"**Iago:**\n...of Nephelococcygia\n* Myrmica ruginodis\n* Myrmica kotokui",
);
// This contains three child elements inside the body tag, pasted
// from LibreOffice Writer, which is correctly classified as not an image.
input = `<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"/><title></title><meta name="generator" content="LibreOffice 25.2.3.2 (Windows)"/><style type="text/css">@page { size: 8.5in 11in; margin: 0.79in }td p { orphans: 0; widows: 0; background: transparent }p { line-height: 115%; margin-bottom: 0.1in; background: transparent }</style></head><body lang="en-US" link="#000080" vlink="#800000" dir="ltr"><p style="line-height: 100%; margin-bottom: 0in">ello world</p><table width="100%" cellpadding="0" cellspacing="0"><col width="51*"/><col width="51*"/><col width="51*"/><col width="51*"/><col width="51*"/><tr valign="top"><td width="20%" style="border: none; padding: 0in"><p>X</p></td><td width="20%" style="border: none; padding: 0in"><p>as</p></td><td width="20%" style="border: none; padding: 0in"><p>Jak</p></td><td width="20%" style="border: none; padding: 0in"><p>J</p></td><td width="20%" style="border: none; padding: 0in"><p>Nm</p></td></tr><tr valign="top"><td width="20%" style="border: none; padding: 0in"><p>,mn</p></td><td width="20%" style="border: none; padding: 0in"><p>,nnf</p></td><td width="20%" style="border: none; padding: 0in"><p>Adlk</p></td><td width="20%" style="border: none; padding: 0in"><p>Asn</p></td><td width="20%" style="border: none; padding: 0in"><p>,amns</p></td></tr><tr valign="top"><td width="20%" style="border: none; padding: 0in"><p>Nm</p></td><td width="20%" style="border: none; padding: 0in"><p>Oi</p></td><td width="20%" style="border: none; padding: 0in"><p>Poi</p></td><td width="20%" style="border: none; padding: 0in"><p>B</p></td><td width="20%" style="border: none; padding: 0in"><p>Ijo</p></td></tr><tr valign="top"><td width="20%" style="border: none; padding: 0in"><p>,mn,</p></td><td width="20%" style="border: none; padding: 0in"><p>;ih</p></td><td width="20%" style="border: none; padding: 0in"><p>Oug</p></td><td width="20%" style="border: none; padding: 0in"><p>Iu</p></td><td width="20%" style="border: none; padding: 0in"><p>G</p></td></tr><tr valign="top"><td width="20%" style="border: none; padding: 0in"><p>Ug</p></td><td width="20%" style="border: none; padding: 0in"><p>Bkjb</p></td><td width="20%" style="border: none; padding: 0in"><p>Kjbk</p></td><td width="20%" style="border: none; padding: 0in"><p>;jbj</p></td><td width="20%" style="border: none; padding: 0in"><p>;jb;</p></td></tr><tr valign="top"><td width="20%" style="border: none; padding: 0in"><p>Bkjb</p></td><td width="20%" style="border: none; padding: 0in"><p>Ugug</p></td><td width="20%" style="border: none; padding: 0in"><p>I9</p></td><td width="20%" style="border: none; padding: 0in"><p>68</p></td><td width="20%" style="border: none; padding: 0in"><p>0</p></td></tr><tr valign="top"><td width="20%" style="border: none; padding: 0in"><p>90kjb</p></td><td width="20%" style="border: none; padding: 0in"><p>,bnbiu</p></td><td width="20%" style="border: none; padding: 0in"><p>Ofif</p></td><td width="20%" style="border: none; padding: 0in"><p>P8gp</p></td><td width="20%" style="border: none; padding: 0in"><p>pugp</p></td></tr></table><p style="line-height: 100%; margin-bottom: 0in"><br/></p></body></html>`;
assert.ok(!compose_paste.is_single_image(input));
// <body> has a single child element which is not a <table> pasted
// from LibreOffice Writer should get pasted normally.
input = `<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"/><title></title><meta name="generator" content="LibreOffice 25.2.3.2 (Windows)"/><style type="text/css">@page { size: 8.5in 11in; margin: 0.79in }p { line-height: 115%; margin-bottom: 0.1in; background: transparent }</style></head><body lang="en-US" link="#000080" vlink="#800000" dir="ltr"><p style="line-height: 100%; margin-bottom: 0in">Hello world this is some random text.</p></body></html>`;
assert.ok(!compose_paste.is_single_image(input));
// A single table pasted from LibreOffice Writer is incorrectly
// detected as a LibreOffice Calc table.
// See https://github.com/zulip/zulip/pull/34752/#discussion_r2113598064
input = `<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"/><title></title><meta name="generator" content="LibreOffice 25.2.3.2 (Windows)"/><style type="text/css">@page { size: 8.5in 11in; margin: 0.79in }p { line-height: 115%; margin-bottom: 0.1in; background: transparent }</style></head><body lang="en-US" link="#000080" vlink="#800000" dir="ltr"><table width="258" cellpadding="2" cellspacing="0"><col width="83"/><col width="81"/><col width="81"/><tr valign="bottom"><td width="83" height="16" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">Melgar</font></p></td><td width="81" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">Female</font></p></td><td width="81" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">UnitedStates</font></p></td></tr><tr valign="bottom"><td width="83" height="16" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">Weiland</font></p></td><td width="81" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">Female</font></p></td><td width="81" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">UnitedStates</font></p></td></tr><tr valign="bottom"><td width="83" height="16" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">Winward</font></p></td><td width="81" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">Female</font></p></td><td width="81" style="border: none; padding: 0in"><p align="left"><font face="Arial, serif">GreatBritain</font></p></td></tr></table></body></html>`;
assert.ok(compose_paste.is_single_image(input));
// Copying an image and pasting it with `paste_html` containing other empty text nodes and
// comment nodes should still classify the `paste_html` as an image that needs to be uploaded.
input = `<html>\n<body>\n<!--StartFragment--><img src="http://zulip.zulipdev.com:9991/user_uploads/thumbnail/2/a0/wETeF-Yv2wmMM6289kO9VC0s/image.png/840x560.webp"/><!--EndFragment-->\n</body>\n</html>`;
assert.ok(compose_paste.is_single_image(input));
// Pasting from the mac terminal
input =
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"><html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv="Content-Style-Type" content="text/css"><title></title><meta name="Generator" content="Cocoa HTML Writer"><meta name="CocoaVersion" content="2575.4"><style type="text/css">p.p1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 11.0px Menlo; color: #000000}span.s1 {font-variant-ligatures: no-common-ligatures}</style></head><body><p class="p1"><span class="s1">insertions</span></p></body></html>';
assert.equal(compose_paste.paste_handler_converter(input), "insertions");
// Zulip timestamp followed by text.
input =
'<meta charset=\'utf-8\'><time datetime="2026-05-29T16:30:00Z" style="background: rgba(0, 0, 0, 0.2); border-radius: 3px; box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 0px 1px; white-space: nowrap; margin: 0px 2px; font-size: 0.95em; color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif;"><span class="timestamp-content-wrapper" style="font-size: 1.0526em; padding: 0px 0.2em; display: inline-flex; align-items: baseline; gap: 3px;"><span></span>May 29, 2026, 10:00 PM</span></time><span style="color: rgb(222, 222, 222); display: inline !important; float: none;"><span> </span>good better<span> </span></span>';
assert.equal(
compose_paste.paste_handler_converter(input),
"<time:2026-05-29T16:30:00Z> good better",
);
// Chrome-stripped timestamp: Chrome's clipboard serializer removes the <time>
// wrapper, leaving only the clock icon <i> and the <span data-datetime> fallback.
input =
'<meta charset=\'utf-8\'><br class="Apple-interchange-newline"><i class="zulip-icon zulip-icon-clock markdown-timestamp-icon" style="text-transform: none; font-size: 15.9995px; line-height: 15.9995px; text-decoration-line: inherit; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline-block; speak: none; font-family: zulip-icons !important; font-style: normal !important; font-weight: 400; font-variant-ligatures: normal; font-variant-caps: normal; font-variant-numeric: normal !important; font-variant-east-asian: normal !important; font-variant-alternates: normal !important; font-variant-position: normal !important; font-variant-emoji: normal !important; align-self: center; color: rgb(222, 222, 222); letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: nowrap; background-color: rgba(0, 0, 0, 0.2);"></i><span data-datetime="2026-05-23T17:30:00Z" style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 15.9995px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: nowrap; background-color: rgba(0, 0, 0, 0.2); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">Sat, May 23, 2026, 11:00 PM</span>';
assert.equal(compose_paste.paste_handler_converter(input), "<time:2026-05-23T17:30:00Z>");
// Non-Zulip <time datetime> elements, should not be converted.
input = "<meta charset='utf-8'><time datetime=\"2024-06-01T09:00:00Z\">June 1, 2024</time>";
assert.equal(compose_paste.paste_handler_converter(input), "June 1, 2024");
// Math block tests
/*
This first batch of math block tests uses captured fixtures
(`input`). This lets us verify behavior like the empty
`.katex-display` divs in case of newlines in the
`original_markdown` See
https://github.com/zulip/zulip/pull/32629#discussion_r1883810127
*/
for (const math_block_test of katex_tests.math_block_tests) {
input = math_block_test.input;
assert.equal(compose_paste.paste_handler_converter(input), math_block_test.expected_output);
}
// This next batch of tests round-trips the LaTeX syntax through
// the Markdown processor and then the paste handler.
const dummy_helper_config = {
should_translate_emoticons: () => false,
get_linkifier_map: () => new Map(),
};
assert.equal(dummy_helper_config.should_translate_emoticons(), false);
assert.deepEqual(dummy_helper_config.get_linkifier_map(), new Map());
for (const inline_math_expression_test of katex_tests.inline_math_expression_tests) {
const paste_html = parse({
raw_content: inline_math_expression_test.original_markup,
helper_config: dummy_helper_config,
}).content;
assert.equal(
compose_paste.paste_handler_converter(paste_html),
inline_math_expression_test.expected_output,
);
}
for (const span_conversion_test of katex_tests.text_node_to_span_conversion_tests) {
const paste_html = parse({
raw_content: span_conversion_test.original_markup,
helper_config: dummy_helper_config,
}).content;
assert.equal(
compose_paste.paste_handler_converter(paste_html),
span_conversion_test.expected_output,
);
}
const meta = '<meta http-equiv="content-type" content="text/html; charset=utf-8">';
let firefox_input;
// Non-silent user mention
firefox_input =
meta +
'<p><span class="user-mention" data-user-id="10">' +
'<span class="mention-content-wrapper">@King Hamlet</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@**King Hamlet|10**");
// Guest user mention: rendered_markdown.ts adds a "(guest)" suffix
// to the display name (commit 70c9d0765f). The people-store lookup
// returns the canonical full_name, so the suffix is dropped naturally.
firefox_input =
meta +
'<p><span class="user-mention" data-user-id="13">' +
'<span class="mention-content-wrapper">@Polonius (guest)</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@**Polonius|13**");
// Silent user mention
firefox_input =
meta +
'<p><span class="user-mention silent" data-user-id="10">' +
'<span class="mention-content-wrapper">King Hamlet</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@_**King Hamlet|10**");
// Self-mention adds a `user-mention-me` modifier class.
firefox_input =
meta +
'<p><span class="user-mention user-mention-me" data-user-id="9">' +
'<span class="mention-content-wrapper">@Desdemona</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@**Desdemona|9**");
// Non-silent group mention (single-asterisk syntax)
firefox_input =
meta +
'<p><span class="user-group-mention" data-user-group-id="24">' +
'<span class="mention-content-wrapper">@hamletcharacters</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@*hamletcharacters*");
// Silent group mention
firefox_input =
meta +
'<p><span class="user-group-mention silent" data-user-group-id="24">' +
'<span class="mention-content-wrapper">hamletcharacters</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@_*hamletcharacters*");
// System group mention: the pill's display text ("Owners") differs from
// the mention name ("role:owners"), so we resolve the name by group id.
firefox_input =
meta +
'<p><span class="user-group-mention" data-user-group-id="25">' +
'<span class="mention-content-wrapper">@Owners</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@*role:owners*");
// Non-silent topic mention
firefox_input =
meta +
'<p><span class="topic-mention">' +
'<span class="mention-content-wrapper">@topic</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@**topic**");
// Silent topic mention
firefox_input =
meta +
'<p><span class="topic-mention silent">' +
'<span class="mention-content-wrapper">topic</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@_**topic**");
// Non-silent channel wildcard mention (no |user_id suffix)
firefox_input =
meta +
'<p><span class="user-mention channel-wildcard-mention" data-user-id="*">' +
'<span class="mention-content-wrapper">@all</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@**all**");
// Silent channel wildcard mention
firefox_input =
meta +
'<p><span class="user-mention channel-wildcard-mention silent" data-user-id="*">' +
'<span class="mention-content-wrapper">all</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@_**all**");
// Mention inline with surrounding text.
firefox_input =
meta +
'<p>Hey <span class="user-mention" data-user-id="10">' +
'<span class="mention-content-wrapper">@King Hamlet</span></span>, how are you?</p>';
assert.equal(
compose_paste.paste_handler_converter(firefox_input),
"Hey @**King Hamlet|10**, how are you?",
);
// Lone mention without ancestor wrapper (bare span), as can happen
// when keyboard selection contains only the mention element.
firefox_input =
meta +
'<span class="user-mention" data-user-id="10">' +
'<span class="mention-content-wrapper">@King Hamlet</span></span>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@**King Hamlet|10**");
// Unknown user (not in the people store) falls back to the id-only
// @**|id** syntax; the backend resolves it on send.
firefox_input =
meta +
'<p><span class="user-mention" data-user-id="9999">' +
'<span class="mention-content-wrapper">@Unknown</span></span></p>';
assert.equal(compose_paste.paste_handler_converter(firefox_input), "@**|9999**");
let chrome_input;
// Non-silent user mention.
chrome_input = `<html><body><!--StartFragment--><span class="user-mention" data-user-id="10" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgba(255, 255, 255, 0.8); background-color: rgba(100, 100, 206, 0.25); cursor: pointer; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">@King Hamlet</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(31, 40, 40); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">, how are you?</span><!--EndFragment--></body></html>`;
assert.equal(
compose_paste.paste_handler_converter(chrome_input),
"@**King Hamlet|10**, how are you?",
);
// Silent user mention.
chrome_input = `<html><body><!--StartFragment--><span class="user-mention silent" data-user-id="10" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgba(255, 255, 255, 0.8); background-color: rgba(100, 100, 206, 0.45); cursor: pointer; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">King Hamlet</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(31, 40, 40); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">, silent test</span><!--EndFragment--></body></html>`;
assert.equal(
compose_paste.paste_handler_converter(chrome_input),
"@_**King Hamlet|10**, silent test",
);
// Self-mention adds a user-mention-me modifier class.
chrome_input = `<html><body><!--StartFragment--><span class="user-mention user-mention-me" data-user-id="9" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgb(194, 194, 255); background-color: rgba(100, 100, 206, 0.45); cursor: pointer; font-weight: 600; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">@Desdemona</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(35, 35, 46); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"> hello</span><!--EndFragment--></body></html>`;
assert.equal(compose_paste.paste_handler_converter(chrome_input), "@**Desdemona|9** hello");
// Guest user mention: rendered_markdown.ts adds a "(guest)" suffix to
// the display name (commit 70c9d0765f). The people-store lookup returns
// the canonical full_name, so the suffix is dropped naturally.
chrome_input = `<html><body><!--StartFragment--><span class="user-mention" data-user-id="13" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgba(255, 255, 255, 0.8); background-color: rgba(100, 100, 206, 0.45); cursor: pointer; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">@Polonius (guest)</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(34, 34, 34); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"><span> </span>guest</span><!--EndFragment--></body></html>`;
assert.equal(compose_paste.paste_handler_converter(chrome_input), "@**Polonius|13** guest");
// Non-silent group mention (single-asterisk syntax).
chrome_input = `<html><body><!--StartFragment--><span class="user-group-mention user-mention-me" data-user-group-id="35" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; cursor: pointer; color: rgb(112, 203, 210); background-color: rgba(49, 150, 155, 0.2); font-weight: 600; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">@announce</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(31, 40, 40); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"> group ping</span><!--EndFragment--></body></html>`;
assert.equal(compose_paste.paste_handler_converter(chrome_input), "@*announce* group ping");
// Silent group mention.
chrome_input = `<html><body><!--StartFragment--><span class="user-group-mention silent" data-user-group-id="24" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; cursor: pointer; color: rgba(255, 255, 255, 0.8); background-color: rgba(49, 150, 155, 0.3); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">hamletcharacters</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(31, 40, 40); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">, silent group</span><!--EndFragment--></body></html>`;
assert.equal(
compose_paste.paste_handler_converter(chrome_input),
"@_*hamletcharacters*, silent group",
);
// Non-silent topic mention.
chrome_input = `<html><body><!--StartFragment--><span class="topic-mention user-mention-me" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgb(112, 203, 210); background-color: rgba(49, 150, 155, 0.2); font-weight: 600; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">@topic</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(31, 40, 40); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"> topic test</span><!--EndFragment--></body></html>`;
assert.equal(compose_paste.paste_handler_converter(chrome_input), "@**topic** topic test");
// Silent topic mention.
chrome_input = `<html><body><!--StartFragment--><span class="topic-mention silent" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgba(255, 255, 255, 0.8); background-color: rgba(49, 150, 155, 0.2); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">topic</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(34, 34, 34); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"> silent topic</span><!--EndFragment--></body></html>`;
assert.equal(compose_paste.paste_handler_converter(chrome_input), "@_**topic** silent topic");
// Non-silent channel wildcard mention (no |user_id suffix).
chrome_input = `<html><body><!--StartFragment--><span class="user-mention channel-wildcard-mention user-mention-me" data-user-id="*" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgb(112, 203, 210); background-color: rgba(49, 150, 155, 0.2); cursor: pointer; font-weight: 600; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">@all</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(31, 40, 40); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"> wildcard</span><!--EndFragment--></body></html>`;
assert.equal(compose_paste.paste_handler_converter(chrome_input), "@**all** wildcard");
// Silent channel wildcard mention.
chrome_input = `<html><body><!--StartFragment--><span class="user-mention channel-wildcard-mention silent user-mention-me" data-user-id="*" style="padding: 0px 3px; border-radius: 3px; white-space: nowrap; font-size: 0.95em; color: rgb(112, 203, 210); background-color: rgba(49, 150, 155, 0.2); cursor: pointer; font-weight: 600; font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="mention-content-wrapper" style="font-size: 1.0526em;">all</span></span><span style="color: rgb(222, 222, 222); font-family: &quot;Source Sans 3 VF&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(31, 40, 40); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;"> silently</span><!--EndFragment--></body></html>`;
assert.equal(compose_paste.paste_handler_converter(chrome_input), "@_**all** silently");
});

View File

@@ -0,0 +1,300 @@
"use strict";
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const input_pill = mock_esm("../src/input_pill");
const people = zrequire("people");
const compose_pm_pill = zrequire("compose_pm_pill");
const {set_realm} = zrequire("state_data");
set_realm(make_realm());
let pills = {
pill: {},
};
run_test("pills", ({override}) => {
const me = make_user({
email: "me@example.com",
user_id: 30,
full_name: "Me Myself",
});
const othello = make_user({
email: "othello@example.com",
user_id: 1,
full_name: "Othello",
});
const iago = make_user({
email: "iago@zulip.com",
user_id: 2,
full_name: "Iago",
});
const hamlet = make_user({
email: "hamlet@example.com",
user_id: 3,
full_name: "Hamlet",
});
people.initialize_current_user(me.user_id);
people.add_active_user(me);
people.add_active_user(othello);
people.add_active_user(iago);
people.add_active_user(hamlet);
const $recipient_stub = $("#private_message_recipient");
const $pill_container_stub = $.create("pill-container-stub");
$recipient_stub.set_parent($pill_container_stub);
let create_item_handler;
const all_pills = new Map();
pills.appendValidatedData = (item) => {
const id = item.user_id;
assert.ok(!all_pills.has(id));
all_pills.set(id, item);
};
pills.items = () => all_pills.values().toArray();
pills.clear_text = () => {};
pills.clear = () => {
pills = {
pill: {},
};
all_pills.clear();
};
function test_create_item(handler) {
(function test_rejection_path() {
const item = handler(othello.user_id, pills.items());
assert.equal(item, undefined);
})();
(function test_success_path() {
const res = handler(iago.user_id, pills.items());
assert.equal(typeof res, "object");
assert.equal(res.user_id, iago.user_id);
assert.equal(res.full_name, iago.full_name);
})();
(function test_deactivated_pill() {
people.deactivate(iago);
const res = handler(iago.user_id, pills.items());
assert.equal(typeof res, "object");
assert.equal(res.user_id, iago.user_id);
assert.equal(res.full_name, iago.full_name);
assert.ok(res.deactivated);
people.add_active_user(iago);
})();
}
function input_pill_stub(opts) {
assert.equal(opts.$container[0], $pill_container_stub[0]);
create_item_handler = opts.create_item_from_text;
assert.ok(create_item_handler);
return pills;
}
override(input_pill, "create", input_pill_stub);
// We stub the return value of input_pill.create(), manually add widget functions to it.
pills.onPillCreate = (callback) => {
callback();
};
pills.onPillRemove = (callback) => {
callback();
};
let on_pill_create_or_remove_call_count = 0;
compose_pm_pill.initialize({
on_pill_create_or_remove() {
on_pill_create_or_remove_call_count += 1;
},
});
assert.ok(compose_pm_pill.widget);
// Called two times via our overridden onPillCreate and onPillRemove methods.
// Normally these would be called via `set_from_typeahead` method.
assert.equal(on_pill_create_or_remove_call_count, 2);
compose_pm_pill.set_from_typeahead(othello);
compose_pm_pill.set_from_typeahead(hamlet);
let user_ids = compose_pm_pill.get_user_ids();
assert.deepEqual(user_ids, [othello.user_id, hamlet.user_id]);
const user_ids_string = compose_pm_pill.get_user_ids_string();
assert.equal(user_ids_string, "1,3");
const emails = compose_pm_pill.get_emails();
assert.equal(emails, "othello@example.com,hamlet@example.com");
const persons = [othello, iago, hamlet];
const items = compose_pm_pill.filter_taken_users(persons);
assert.deepEqual(items, [iago]);
test_create_item(create_item_handler);
compose_pm_pill.clear();
compose_pm_pill.set_from_typeahead(me);
compose_pm_pill.set_from_typeahead(othello);
user_ids = compose_pm_pill.get_user_ids();
assert.deepEqual(user_ids, [othello.user_id]);
compose_pm_pill.set_from_user_ids([hamlet.user_id], true);
user_ids = compose_pm_pill.get_user_ids();
assert.deepEqual(user_ids, [hamlet.user_id]);
});
run_test("has_unconverted_data", ({override}) => {
override(compose_pm_pill.widget, "is_pending", () => true);
// If the pill itself has pending data, we have unconverted
// data.
assert.equal(compose_pm_pill.has_unconverted_data(), true);
override(compose_pm_pill.widget, "is_pending", () => false);
override(compose_pm_pill.widget, "items", () => [{user_id: 99}]);
// Our pill is complete and all items contain user_id, so
// we do NOT have unconverted data.
assert.equal(compose_pm_pill.has_unconverted_data(), false);
override(compose_pm_pill.widget, "items", () => [{user_id: 99}, {email: "random@mit.edu"}]);
// One of our items only knows email (as in a bridge
// scenario where we might not have registered the user yet), so
// we have some unconverted data.
assert.equal(compose_pm_pill.has_unconverted_data(), true);
});
run_test("update_user_pill_active_status", ({override_rewire}) => {
const othello = make_user({
email: "othello@example.com",
user_id: 1,
full_name: "Othello",
is_active: true,
});
const inaccessible_user = make_user({
email: "iago@zulip.com",
user_id: 2,
full_name: "Iago",
is_active: false,
is_inaccessible_user: true,
});
// Test with uninitialized widget - should return early without error
override_rewire(compose_pm_pill, "widget", undefined);
// No assertions needed - we're just checking it doesn't throw
compose_pm_pill.update_user_pill_active_status(othello, false);
// Set up test data for normal widget tests
const pills_data = [
{
item: {user_id: othello.user_id, full_name: othello.full_name},
$element: {0: {id: "pill_1"}},
},
{
item: {user_id: inaccessible_user.user_id, full_name: inaccessible_user.full_name},
$element: {0: {id: "pill_2"}},
},
];
let pill_updated = false;
const widget = {
getPillByPredicate(predicate) {
return pills_data.find((p) => predicate(p.item));
},
updatePill(element, item) {
const pill = pills_data.find((p) => p.$element[0] === element);
pill.item = item;
pill_updated = true;
},
};
override_rewire(compose_pm_pill, "widget", widget);
// Test deactivating a user - should set deactivated to true
compose_pm_pill.update_user_pill_active_status(othello, false);
assert.deepEqual(pills_data[0].item, {
user_id: othello.user_id,
full_name: othello.full_name,
deactivated: true,
});
assert.ok(pill_updated);
// Reset for next test
pill_updated = false;
// Test reactivating a user - should set deactivated to false
compose_pm_pill.update_user_pill_active_status(othello, true);
assert.deepEqual(pills_data[0].item, {
user_id: othello.user_id,
full_name: othello.full_name,
deactivated: false,
});
assert.ok(pill_updated);
// Reset for next test
pill_updated = false;
// Test deactivating an inaccessible user - should keep deactivated as false
// We consider inaccessible users as active to avoid falsely showing them as deactivated,
// since we don't have information about their activity status.
compose_pm_pill.update_user_pill_active_status(inaccessible_user, false);
assert.deepEqual(pills_data[1].item, {
user_id: inaccessible_user.user_id,
full_name: inaccessible_user.full_name,
deactivated: false,
});
assert.ok(pill_updated);
// Reset for next test
pill_updated = false;
// Test updating a user that doesn't have a pill - should be a no-op
const hamlet = make_user({
email: "hamlet@example.com",
user_id: 3,
full_name: "Hamlet",
});
compose_pm_pill.update_user_pill_active_status(hamlet, true);
assert.ok(!pill_updated);
});
run_test("update_user_pill_full_name", ({override_rewire}) => {
// The pill-update logic lives in user_pill.update_pill_full_name and
// is tested there; here we just verify the wrapper delegates when
// the widget is set, and is a no-op otherwise.
let user_pill_function_called = false;
const widget = {
getPillByPredicate() {
user_pill_function_called = true;
return undefined;
},
updatePill() {},
};
override_rewire(compose_pm_pill, "widget", undefined);
compose_pm_pill.update_user_pill_full_name(1, "Othello the Moor");
assert.ok(!user_pill_function_called);
override_rewire(compose_pm_pill, "widget", widget);
compose_pm_pill.update_user_pill_full_name(1, "Othello the Moor");
assert.ok(user_pill_function_called);
});

View File

@@ -0,0 +1,439 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire, mock_esm} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const compose_reply = zrequire("compose_reply");
const message_store = zrequire("message_store");
const message_fetch_raw_content = mock_esm("../src/message_fetch_raw_content");
const compose_paste = mock_esm("../src/compose_paste");
const message_lists = mock_esm("../src/message_lists");
const pm_user_ids_1 = "1,2";
const pm_user_ids_2 = "3,4";
const msg_pm_1 = {type: "private", to_user_ids: pm_user_ids_1};
const msg_pm_2 = {type: "private", to_user_ids: pm_user_ids_1};
const msg_pm_3 = {type: "private", to_user_ids: pm_user_ids_2};
const msg_stream_denmark_general = {type: "stream", stream_id: 10, topic: "general"};
// Topic case should be ignored by all_messages_have_same_recipient
const msg_stream_denmark_general_caps = {type: "stream", stream_id: 10, topic: "GENERAL"};
const msg_stream_denmark_design = {type: "stream", stream_id: 10, topic: "design"};
const msg_stream_sweden_general = {type: "stream", stream_id: 20, topic: "general"};
function set_message_lists_current(may_contain_multiple) {
message_lists.current = {
data: {
filter: {
may_contain_multiple_conversations() {
return may_contain_multiple;
},
},
},
};
}
run_test("all_messages_have_same_recipient", () => {
// When the filter is set to channel+topic or a dm
// conversation.
set_message_lists_current(false);
assert.equal(
compose_reply.all_messages_have_same_recipient([
msg_stream_denmark_general,
msg_stream_denmark_general_caps,
]),
true,
"Return true if existing narrow cannot possibly have more than one recipients",
);
// Assertions on empty arrays
assert.throws(() => compose_reply.all_messages_have_same_recipient([]), Error);
set_message_lists_current(true);
// Private messages
assert.ok(compose_reply.all_messages_have_same_recipient([msg_pm_1]));
assert.ok(compose_reply.all_messages_have_same_recipient([msg_pm_1, msg_pm_2]));
assert.equal(
compose_reply.all_messages_have_same_recipient([msg_pm_1, msg_pm_2, msg_pm_3]),
false,
"Fails if private message recipients differ",
);
// Channel messages
assert.ok(compose_reply.all_messages_have_same_recipient([msg_stream_denmark_general]));
assert.ok(
compose_reply.all_messages_have_same_recipient([
msg_stream_denmark_general,
msg_stream_denmark_general_caps,
]),
"Topic matching should be case-insensitive",
);
assert.equal(
compose_reply.all_messages_have_same_recipient([
msg_stream_denmark_general,
msg_stream_denmark_design,
]),
false,
"Return false if topics differ",
);
assert.equal(
compose_reply.all_messages_have_same_recipient([
msg_stream_denmark_general,
msg_stream_sweden_general,
]),
false,
"Return false if stream IDs differ for channel messages",
);
// Mixed types
assert.equal(
compose_reply.all_messages_have_same_recipient([msg_pm_1, msg_stream_denmark_general]),
false,
"Return false on mixed stream/private messages",
);
assert.equal(
compose_reply.all_messages_have_same_recipient([msg_stream_denmark_general, msg_pm_1]),
false,
"Return false on mixed stream/private messages",
);
});
run_test("all_messages_have_same_channel", () => {
// Assertions on empty arrays
assert.throws(() => compose_reply.all_messages_have_same_channel([]), Error);
// Messages involving private messages
assert.equal(compose_reply.all_messages_have_same_channel([msg_pm_1, msg_pm_2]), false);
// Channel messages
assert.ok(compose_reply.all_messages_have_same_channel([msg_stream_denmark_general]));
assert.ok(
compose_reply.all_messages_have_same_channel([
msg_stream_denmark_general,
msg_stream_denmark_design,
]),
"Returns true for same stream ID, even if topics differ",
);
assert.equal(
compose_reply.all_messages_have_same_channel([
msg_stream_denmark_general,
msg_stream_sweden_general,
]),
false,
"Return false if stream IDs differ",
);
// Mixed types of messages.
assert.equal(
compose_reply.all_messages_have_same_channel([msg_stream_denmark_general, msg_pm_1]),
false,
);
assert.equal(
compose_reply.all_messages_have_same_channel([msg_pm_1, msg_stream_denmark_general]),
false,
);
});
run_test("all_messages_are_private", () => {
// Assertions on empty arrays
assert.throws(() => compose_reply.all_messages_are_private([]), Error);
// Stream messages immediately return false
assert.equal(compose_reply.all_messages_are_private([msg_stream_denmark_general]), false);
assert.equal(
compose_reply.all_messages_are_private([msg_stream_denmark_general, msg_pm_1]),
false,
);
// Private messages
assert.ok(compose_reply.all_messages_are_private([msg_pm_1]));
assert.ok(
compose_reply.all_messages_are_private([msg_pm_1, msg_pm_3]),
"Returns true for private messages even if recipients differ",
);
// Mixed types where first is private
assert.equal(
compose_reply.all_messages_are_private([msg_pm_1, msg_stream_denmark_general]),
false,
);
});
run_test("get_multi_message_quote_status", () => {
set_message_lists_current(true);
const channel_1_general = {type: "stream", stream_id: 10, topic: "general"};
const channel_1_general_2 = {type: "stream", stream_id: 10, topic: "general"};
const channel_1_design = {type: "stream", stream_id: 10, topic: "design"};
const channel_2_general = {type: "stream", stream_id: 20, topic: "general"};
const pm_1 = {type: "private", to_user_ids: "1,2"};
const pm_1_b = {type: "private", to_user_ids: "1,2"};
const pm_2 = {type: "private", to_user_ids: "3,4"};
assert.equal(
compose_reply.get_multi_message_quote_status([channel_1_general, channel_1_general_2]),
"SAME_RECIPIENT",
);
assert.equal(compose_reply.get_multi_message_quote_status([pm_1, pm_1_b]), "SAME_RECIPIENT");
assert.equal(
compose_reply.get_multi_message_quote_status([channel_1_general, channel_1_design]),
"SAME_CHANNEL_DIFFERENT_TOPICS",
);
assert.equal(
compose_reply.get_multi_message_quote_status([pm_1, pm_2]),
"DIFFERENT_DM_CONVERSATIONS",
);
// NOTHING_IN_COMMON
// 1. Belong to different channels.
assert.equal(
compose_reply.get_multi_message_quote_status([channel_1_general, channel_2_general]),
"NOTHING_IN_COMMON",
);
// 2. Mixed channel and private messages
assert.equal(
compose_reply.get_multi_message_quote_status([channel_1_general, pm_1]),
"NOTHING_IN_COMMON",
);
});
run_test("get_quote_context_for_message", () => {
set_message_lists_current(true);
const msg_alice = {type: "stream", stream_id: 10, topic: "general", sender_id: 1};
const msg_alice_2 = {type: "stream", stream_id: 10, topic: "general", sender_id: 1};
const msg_bob = {type: "stream", stream_id: 10, topic: "general", sender_id: 2};
const msg_bob_design = {type: "stream", stream_id: 10, topic: "design", sender_id: 2};
const pm_alice = {type: "private", to_user_ids: "1,2", sender_id: 1};
const pm_alice_2 = {type: "private", to_user_ids: "1,2", sender_id: 1};
const pm_bob = {type: "private", to_user_ids: "1,2", sender_id: 2};
const pm_charlie = {type: "private", to_user_ids: "1,2,3", sender_id: 3};
// First message in a quote chain
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: msg_alice,
previous_quoted_message: undefined,
forward_message: false,
is_first_in_quote_chain: true,
}),
"INCLUDE_SENDER_AND_RECIPIENT",
);
// Continuous channel message from the same sender
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: msg_alice_2,
previous_quoted_message: msg_alice,
forward_message: false,
is_first_in_quote_chain: false,
}),
"INCLUDE_NOTHING",
);
// Continuous private message from the same sender
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: pm_alice_2,
previous_quoted_message: pm_alice,
forward_message: false,
is_first_in_quote_chain: false,
}),
"INCLUDE_NOTHING",
);
// Same channel thread, but the sender changed
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: msg_bob,
previous_quoted_message: msg_alice,
forward_message: false,
is_first_in_quote_chain: false,
}),
"INCLUDE_SENDER",
);
// Same private message thread, but the sender changed
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: pm_bob,
previous_quoted_message: pm_alice,
forward_message: false,
is_first_in_quote_chain: false,
}),
"INCLUDE_SENDER",
);
// Channel/topic changed completely
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: msg_bob_design,
previous_quoted_message: msg_alice,
forward_message: false,
is_first_in_quote_chain: false,
}),
"INCLUDE_SENDER_AND_RECIPIENT",
);
// Private message recipients changed completely
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: pm_charlie,
previous_quoted_message: pm_alice,
forward_message: false,
is_first_in_quote_chain: false,
}),
"INCLUDE_SENDER_AND_RECIPIENT",
);
// Quoted individually (no previous message)
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: msg_alice,
previous_quoted_message: undefined,
forward_message: false,
is_first_in_quote_chain: false,
}),
"INCLUDE_SENDER",
);
// Forwarded individually
assert.equal(
compose_reply.get_quote_context_for_message({
current_message: msg_alice,
previous_quoted_message: undefined,
forward_message: true,
is_first_in_quote_chain: false,
}),
"INCLUDE_SENDER_AND_RECIPIENT",
);
});
function add_messages_to_message_store(messages) {
message_store.clear_for_testing();
for (const message of messages) {
message_store.update_message_cache({message});
}
}
run_test("build_and_process_quote_assets_for_messages", ({override}) => {
// Case: We get back the raw_content.
const msg_hydrated = {id: 1, raw_content: "Raw markdown", content: "<p>Raw markdown</p>"};
const msg_unhydrated = {id: 2, content: "<p>unhydrated</p>"};
add_messages_to_message_store([msg_hydrated, msg_unhydrated]);
override(
message_fetch_raw_content,
"get_raw_content_for_messages",
({_message_ids, on_success, _on_error}) => {
on_success(["Raw markdown", "hydrated"]);
},
);
let result_assets = [];
compose_reply.build_and_process_quote_assets_for_messages([1, 2], (assets) => {
result_assets = assets;
});
assert.equal(result_assets.length, 2);
assert.deepEqual(
result_assets[0],
{message: msg_hydrated, quote_content: "Raw markdown"},
"Should use raw_content from the fetched results",
);
assert.deepEqual(
result_assets[1],
{message: msg_unhydrated, quote_content: "hydrated"},
"Should use fetched raw_content",
);
// Case: Network error on trying to get raw_content.
// Here, we should use `message.raw_content` if it's available.
// Else we fallback to using the local paste_handler_converter.
override(
message_fetch_raw_content,
"get_raw_content_for_messages",
({_message_ids, _on_success, on_error}) => {
on_error();
},
);
override(
compose_paste,
"paste_handler_converter",
(content) => `converted_by_turndown: ${content}`,
);
compose_reply.build_and_process_quote_assets_for_messages([1, 2], (assets) => {
result_assets = assets;
});
assert.deepEqual(
result_assets[0],
{message: msg_hydrated, quote_content: "Raw markdown"},
"Should use raw_content when available",
);
assert.deepEqual(
result_assets[1],
{message: msg_unhydrated, quote_content: "converted_by_turndown: <p>unhydrated</p>"},
"Fallback to using paste_handler_converter",
);
});
run_test("get_quote_menu_selection", ({override_rewire}) => {
override_rewire(compose_reply, "get_highlighted_message_ids", () => undefined);
assert.deepEqual(compose_reply.get_quote_menu_selection(10), {
kind: "full_message",
hotkeys_agree: true,
});
override_rewire(compose_reply, "get_highlighted_message_ids", () => [10]);
override_rewire(compose_reply, "get_message_selection", () => "partial text");
assert.deepEqual(compose_reply.get_quote_menu_selection(10), {
kind: "message_selection",
quote_content: "partial text",
hotkeys_agree: true,
});
// The selection is inside a different message than the menu was opened
// on, so > and < would act on the selection instead: hide their hints.
assert.deepEqual(compose_reply.get_quote_menu_selection(99), {
kind: "full_message",
hotkeys_agree: false,
});
// Selecting only a sender name or timestamp lands outside
// `.message_content`, so there is nothing quotable to offer.
override_rewire(compose_reply, "get_message_selection", () => " ".repeat(3));
assert.deepEqual(compose_reply.get_quote_menu_selection(10), {
kind: "full_message",
hotkeys_agree: true,
});
// A selection spanning several messages, opened from inside the range;
// we return early without consulting the selected text at all.
override_rewire(compose_reply, "get_highlighted_message_ids", () => [10, 11, 12]);
assert.deepEqual(compose_reply.get_quote_menu_selection(11), {
kind: "selected_messages",
message_ids: [10, 11, 12],
hotkeys_agree: true,
});
// ...and opened from a message outside the range, where the menu acts on
// that one message only and the hotkeys would not.
assert.deepEqual(compose_reply.get_quote_menu_selection(99), {
kind: "full_message",
hotkeys_agree: false,
});
});

View File

@@ -0,0 +1,53 @@
"use strict";
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const compose_pm_pill = mock_esm("../src/compose_pm_pill");
const compose_state = zrequire("compose_state");
const stream_data = zrequire("stream_data");
const {set_realm} = zrequire("state_data");
const realm = make_realm();
set_realm(realm);
run_test("private_message_recipient_emails", ({override}) => {
override(compose_pm_pill, "get_emails", () => "fred@fred.org");
assert.equal(compose_state.private_message_recipient_emails(), "fred@fred.org");
});
run_test("has_full_recipient", ({override}) => {
$(`#compose_banners .topic_resolved`)[0].remove = noop;
let user_ids;
override(compose_pm_pill, "set_from_user_ids", (value) => {
user_ids = value;
});
override(compose_pm_pill, "get_user_ids", () => user_ids);
compose_state.set_message_type("stream");
compose_state.set_stream_id("");
compose_state.topic("");
assert.equal(compose_state.has_full_recipient(), false);
compose_state.topic("foo");
assert.equal(compose_state.has_full_recipient(), false);
stream_data.add_sub_for_tests(make_stream({name: "bar", stream_id: 99}));
compose_state.set_stream_id(99);
assert.equal(compose_state.has_full_recipient(), true);
compose_state.set_message_type("private");
compose_state.set_private_message_recipient_ids([]);
assert.equal(compose_state.has_full_recipient(), false);
compose_state.set_private_message_recipient_ids([123]);
assert.equal(compose_state.has_full_recipient(), true);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
"use strict";
const assert = require("node:assert/strict");
const _ = require("lodash");
const {make_user, make_bot} = require("./lib/example_user.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const people = zrequire("people");
const {ConversationParticipants} = zrequire("../src/conversation_participants.ts");
const user1 = make_user();
const user2 = make_user();
const bot1 = make_bot();
const bot2 = make_bot();
people._add_user(user1);
people._add_user(user2);
people._add_user(bot1);
people._add_user(bot2);
const human_messages = [
{
id: 1,
sender_id: user1.user_id,
sent_by_me: true,
},
{
id: 2,
sender_id: user2.user_id,
sent_by_me: false,
},
];
const bot_messages = [
{
id: 4,
sender_id: bot1.user_id,
sent_by_me: false,
},
{
id: 5,
sender_id: bot2.user_id,
sent_by_me: false,
},
];
const all_messages = [...human_messages, ...bot_messages];
run_test("Add participants", () => {
const participants = new ConversationParticipants(all_messages);
assert.ok(_.isEqual(participants.humans, new Set([user1.user_id, user2.user_id])));
assert.ok(_.isEqual(participants.bots, new Set([bot1.user_id, bot2.user_id])));
// None since they were not added as active users.
assert.equal(participants.visible().size, 0);
// Add user1 as active user.
people.add_active_user(user1);
assert.ok(_.isEqual(participants.visible(), new Set([user1.user_id])));
});

View File

@@ -0,0 +1,224 @@
"use strict";
const assert = require("node:assert/strict");
const {
format,
startOfDay,
getUnixTime,
parseISO,
isEqual,
subDays,
subWeeks,
subMonths,
} = require("date-fns");
const {clock, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const date_util = zrequire("date_util");
run_test("get_default_search_suggestions", () => {
const today = new Date(2024, 0, 15, 2, 0, 0);
clock.setSystemTime(today.getTime());
const [today_sug, yesterday_sug, day_before_sug, week_ago_sug, month_ago_sug] =
date_util.get_default_search_suggestions();
const expected = (date) => `date:${format(date, "yyyy-MM-dd")}`;
assert.equal(today_sug, expected(today));
assert.equal(yesterday_sug, expected(subDays(today, 1)));
assert.equal(day_before_sug, expected(subDays(today, 2)));
assert.equal(week_ago_sug, expected(subWeeks(today, 1)));
assert.equal(month_ago_sug, expected(subMonths(today, 1)));
clock.reset();
});
run_test("get_matching_default_date_suggestions", () => {
const today = parseISO("2026-03-31");
clock.setSystemTime(today.getTime());
const default_suggestions = {
today: "date:2026-03-31",
yesterday: "date:2026-03-30",
day_before_yesterday: "date:2026-03-29",
a_week_ago: "date:2026-03-24",
a_month_ago: "date:2026-02-28",
};
// Partially matching dates must give back default suggestions
assert.deepEqual(
date_util.get_matching_default_date_suggestions("20"),
Object.values(default_suggestions),
);
assert.deepEqual(
date_util.get_matching_default_date_suggestions("2026"),
Object.values(default_suggestions),
);
assert.deepEqual(date_util.get_matching_default_date_suggestions("2026-03-3"), [
"date:2026-03-31",
"date:2026-03-30",
]);
// Empty operand should give back all default suggestions
assert.deepEqual(
date_util.get_matching_default_date_suggestions(""),
Object.values(default_suggestions),
);
// For coverage.
assert.deepEqual(date_util.get_matching_default_date_suggestions("-"), []);
assert.deepEqual(date_util.get_matching_default_date_suggestions("-01-01"), [
"date:2026-01-01",
]);
// Get suggestions for trailing hyphens/zeros
assert.deepEqual(
date_util.get_matching_default_date_suggestions("2026-"),
Object.values(default_suggestions),
);
assert.deepEqual(
date_util.get_matching_default_date_suggestions("2026-0"),
Object.values(default_suggestions),
);
// operand which doesn't match any default suggestions and pill labels.
assert.deepEqual(date_util.get_matching_default_date_suggestions("2023-02"), [
"date:2023-02-01",
]);
assert.deepEqual(date_util.get_matching_default_date_suggestions("2023-02-0"), [
"date:2023-02-01",
]);
assert.deepEqual(date_util.get_matching_default_date_suggestions("2023-0-"), [
"date:2023-01-01",
]);
assert.deepEqual(date_util.get_matching_default_date_suggestions("2023"), ["date:2023-01-01"]);
// operand doesn't match any default suggestions but matches pill labels.
assert.deepEqual(date_util.get_matching_default_date_suggestions("to"), [
default_suggestions.today,
]);
assert.deepEqual(date_util.get_matching_default_date_suggestions("a "), [
default_suggestions.a_week_ago,
default_suggestions.a_month_ago,
]);
assert.deepEqual(date_util.get_matching_default_date_suggestions("month"), [
default_suggestions.a_month_ago,
]);
// Gibberish shouldn't yield any suggestions
assert.deepEqual(date_util.get_matching_default_date_suggestions("lkmvlckakj"), []);
});
run_test("maybe_get_parsed_iso_8601_date", () => {
const d1 = date_util.maybe_get_parsed_iso_8601_date("2026-03-28");
assert.ok(isEqual(d1, parseISO("2026-03-28")));
const d2 = date_util.maybe_get_parsed_iso_8601_date("2026");
assert.equal(d2, undefined);
const d3 = date_util.maybe_get_parsed_iso_8601_date("2026-01");
assert.equal(d3, undefined);
});
run_test("get_search_pill_value", () => {
// Non-date operand is returned as-is.
assert.equal(date_util.get_search_pill_value("not-a-date"), "not-a-date");
const today = new Date();
const today_str = format(today, "yyyy-MM-dd");
assert.equal(date_util.get_search_pill_value(today_str), "today");
const yesterday = subDays(today, 1);
const yesterday_str = format(yesterday, "yyyy-MM-dd");
assert.equal(date_util.get_search_pill_value(yesterday_str), "yesterday");
const day_before_yesterday = subDays(today, 2);
const day_before_yesterday_str = format(day_before_yesterday, "yyyy-MM-dd");
assert.equal(
date_util.get_search_pill_value(day_before_yesterday_str),
day_before_yesterday_str,
);
const a_week_ago = subWeeks(today, 1);
const a_week_ago_str = format(a_week_ago, "yyyy-MM-dd");
assert.equal(date_util.get_search_pill_value(a_week_ago_str), "a week ago");
const a_month_ago = subMonths(today, 1);
const a_month_ago_str = format(a_month_ago, "yyyy-MM-dd");
assert.equal(date_util.get_search_pill_value(a_month_ago_str), "a month ago");
// Some other valid date should be returned unchanged.
assert.equal(date_util.get_search_pill_value("2000-01-01"), "2000-01-01");
});
run_test("is_date_valid", () => {
const today = new Date(2026, 0, 15, 2, 0, 0);
clock.setSystemTime(today.getTime());
assert.ok(date_util.is_date_valid(new Date(2024, 0, 15, 2, 0, 0)));
assert.ok(date_util.is_date_valid(new Date(2022, 4, 14, 0, 0, 0)));
assert.ok(!date_util.is_date_valid(undefined));
assert.ok(!date_util.is_date_valid(new Date(2028, 0, 0, 0, 0, 0)));
assert.ok(!date_util.is_date_valid(new Date(1500, 0, 0, 0, 0, 0)));
clock.reset();
});
run_test("convert_date_str_to_description_date", () => {
assert.equal(date_util.convert_date_str_to_description_date("2025-05-12"), "May 12, 2025");
assert.equal(date_util.convert_date_str_to_description_date("foobar"), "foobar");
});
run_test("get_unix_seconds_for_local_midnight", () => {
const now = new Date(2026, 2, 10, 15, 30, 0);
clock.setSystemTime(now.getTime());
const expected_ts = getUnixTime(startOfDay(now));
assert.equal(date_util.get_unix_seconds_for_local_midnight(), expected_ts);
// When no argument is given, we use "today" based on local time and zero out HH:MM:SS.
const local_now = new Date();
const expected_midnight = new Date(local_now);
expected_midnight.setHours(0, 0, 0, 0);
assert.equal(
date_util.get_unix_seconds_for_local_midnight(),
Math.floor(expected_midnight.getTime() / 1000),
);
// With an explicit date string.
const some_date_str = "2024-02-20";
const ts = date_util.get_unix_seconds_for_local_midnight(some_date_str);
const round_tripped = new Date(ts * 1000);
assert.equal(round_tripped.getFullYear(), 2024);
assert.equal(round_tripped.getMonth(), 1);
assert.equal(round_tripped.getDate(), 20);
assert.equal(round_tripped.getHours(), 0);
assert.equal(round_tripped.getMinutes(), 0);
assert.equal(round_tripped.getSeconds(), 0);
clock.reset();
const bad_result = date_util.get_unix_seconds_for_local_midnight("invalid-date");
assert.equal(bad_result, null);
});
run_test("maybe_get_date_anchor", () => {
const result = date_util.maybe_get_date_anchor("2024-02-20");
assert.ok(result);
assert.equal(result.anchor, "date");
// anchor_date is a UTC ISO string for local midnight on the
// operand date; verify it round-trips to that local day.
const parsed = new Date(result.anchor_date);
assert.equal(parsed.getFullYear(), 2024);
assert.equal(parsed.getMonth(), 1);
assert.equal(parsed.getDate(), 20);
assert.equal(parsed.getHours(), 0);
assert.equal(parsed.getMinutes(), 0);
assert.equal(parsed.getSeconds(), 0);
assert.equal(date_util.maybe_get_date_anchor("invalid-date"), undefined);
});

View File

@@ -0,0 +1,28 @@
"use strict";
const assert = require("node:assert/strict");
const {addDays} = require("date-fns");
const {make_realm} = require("./lib/example_realm.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const demo_organization_ui = zrequire("demo_organizations_ui");
const {set_realm} = zrequire("state_data");
const realm = make_realm();
set_realm(realm);
run_test("get_demo_organization_deadline_days_remaining", ({override}) => {
const start_time = new Date("2024-01-01T10:00:00.000Z"); // Wednesday 1/1/2024 10:00:00 AM (UTC+0)
override(Date, "now", () => start_time);
const demo_organization_scheduled_deletion_date = addDays(start_time, 7); // Wednesday 1/8/2024 10:00:00 AM (UTC+0)
override(
realm,
"demo_organization_scheduled_deletion_date",
Math.trunc(demo_organization_scheduled_deletion_date / 1000),
);
assert.equal(demo_organization_ui.get_demo_organization_deadline_days_remaining(), 7);
});

View File

@@ -0,0 +1,19 @@
"use strict";
const assert = require("node:assert/strict");
const {set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
set_global("navigator", {
userAgent: "",
});
const deprecated_feature_notice = zrequire("deprecated_feature_notice");
run_test("get_hotkey_deprecation_notice", () => {
const expected =
'translated: We\'ve replaced the "Shift + C" hotkey with "X" to make this common shortcut easier to trigger.';
const actual = deprecated_feature_notice.get_hotkey_deprecation_notice("Shift + C", "X");
assert.equal(actual, expected);
});

View File

@@ -0,0 +1,88 @@
"use strict";
const assert = require("node:assert/strict");
const {make_user} = require("./lib/example_user.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const direct_message_group_data = zrequire("direct_message_group_data");
const people = zrequire("people");
function create_user(info) {
const user = make_user(info);
people.add_active_user(user);
return user;
}
const me = create_user({
email: "me@zulip.com",
user_id: 999,
full_name: "Me Myself",
});
const alice = create_user({
email: "alice@zulip.com",
user_id: 1,
full_name: "Alice Smith",
});
const fred = create_user({
email: "fred@zulip.com",
user_id: 2,
full_name: "Fred Flintstone",
});
const jill = create_user({
email: "jill@zulip.com",
user_id: 3,
full_name: "Jill Hill",
});
const norbert = create_user({
email: "norbert@zulip.com",
user_id: 5,
full_name: "Norbert Oswald",
});
people.initialize_current_user(me.user_id);
run_test("direct_message_group_data.process_loaded_messages", () => {
const timestamp1 = 1382479029; // older
const timestamp2 = 1382479033; // newer
const old_timestamp = 1382479000;
const messages = [
{
type: "private",
display_recipient: [{id: jill.user_id}, {id: norbert.user_id}],
timestamp: timestamp1,
},
{
type: "stream",
},
// direct message to myself
{
type: "private",
display_recipient: [{id: me.user_id}],
},
{
type: "private",
display_recipient: [{id: alice.user_id}, {id: fred.user_id}],
timestamp: timestamp2,
},
{
type: "private",
display_recipient: [{id: fred.user_id}, {id: alice.user_id}],
timestamp: old_timestamp,
},
];
direct_message_group_data.process_loaded_messages(messages);
assert.deepEqual(direct_message_group_data.get_direct_message_groups(), [
`${alice.user_id},${fred.user_id}`,
`${jill.user_id},${norbert.user_id}`,
]);
});

1829
web/tests/dispatch.test.cjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
"use strict";
const assert = require("node:assert/strict");
const events = require("./lib/events.cjs");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {make_stub} = require("./lib/stub.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const event_fixtures = events.fixtures;
const test_user = events.test_user;
const compose_recipient = mock_esm("../src/compose_recipient");
mock_esm("../src/inbox_ui", {
complete_rerender: noop,
});
const message_events = mock_esm("../src/message_events");
const overlays = mock_esm("../src/overlays");
mock_esm("../src/recent_view_ui", {
complete_rerender: noop,
});
const settings_org = mock_esm("../src/settings_org");
const settings_streams = mock_esm("../src/settings_streams");
const stream_events = mock_esm("../src/stream_events");
const stream_list = mock_esm("../src/stream_list");
const stream_settings_ui = mock_esm("../src/stream_settings_ui");
const unread_ops = mock_esm("../src/unread_ops");
const user_group_edit = mock_esm("../src/user_group_edit");
const compose_state = zrequire("compose_state");
const peer_data = zrequire("peer_data");
const people = zrequire("people");
const server_events_dispatch = zrequire("server_events_dispatch");
const {set_realm} = zrequire("state_data");
const stream_data = zrequire("stream_data");
const sub_store = zrequire("sub_store");
const realm = make_realm();
set_realm(realm);
people.add_active_user(test_user);
const me = make_user({
email: "me@zulip.com",
full_name: "Me Myself",
user_id: 101,
});
people.add_active_user(me);
people.initialize_current_user(me.user_id);
const dispatch = server_events_dispatch.dispatch_normal_event;
function test(label, f) {
run_test(label, (helpers) => {
stream_data.clear_subscriptions();
f(helpers);
});
}
test("add", ({override}) => {
const event = event_fixtures.subscription__add;
const sub = event.subscriptions[0];
const stream_id = sub.stream_id;
stream_data.add_sub_for_tests(
make_stream({
stream_id,
name: sub.name,
}),
);
const subscription_stub = make_stub();
override(stream_events, "mark_subscribed", subscription_stub.f);
dispatch(event);
assert.equal(subscription_stub.num_calls, 1);
const args = subscription_stub.get_args("sub", "subscribers");
assert.deepEqual(args.sub.stream_id, stream_id);
assert.deepEqual(args.subscribers, event.subscriptions[0].subscribers);
});
test("peer add/remove", ({override}) => {
let event = event_fixtures.subscription__peer_add;
const devel_stream_id = event.stream_ids[0];
stream_data.add_sub_for_tests(
make_stream({
name: "devel",
stream_id: devel_stream_id,
}),
);
const stream_stub = make_stub();
override(stream_events, "process_subscriber_update", stream_stub.f);
dispatch(event);
assert.equal(stream_stub.num_calls, 1);
assert.ok(peer_data.is_user_loaded_and_subscribed(devel_stream_id, event.user_ids[0]));
event = event_fixtures.subscription__peer_remove;
dispatch(event);
assert.equal(stream_stub.num_calls, 2);
assert.ok(!peer_data.is_user_loaded_and_subscribed(devel_stream_id, event.user_ids[0]));
});
test("remove", ({override}) => {
const event = event_fixtures.subscription__remove;
const event_sub = event.subscriptions[0];
const stream_id = event_sub.stream_id;
const sub = make_stream({
stream_id,
name: event_sub.name,
});
stream_data.add_sub_for_tests(sub);
const stub = make_stub();
override(stream_events, "mark_unsubscribed", stub.f);
dispatch(event);
assert.equal(stub.num_calls, 1);
const args = stub.get_args("sub");
assert.deepEqual(args.sub, sub);
});
test("update", ({override}) => {
const event = event_fixtures.subscription__update;
const stub = make_stub();
override(stream_events, "update_property", stub.f);
dispatch(event);
assert.equal(stub.num_calls, 1);
const args = stub.get_args("stream_id", "property", "value");
assert.deepEqual(args.stream_id, event.stream_id);
assert.deepEqual(args.property, event.property);
assert.deepEqual(args.value, event.value);
});
test("add error handling", () => {
// test blueslip errors/warns
const event = event_fixtures.subscription__add;
blueslip.expect("error", "Subscribing to unknown stream");
dispatch(event);
blueslip.reset();
});
test("peer event error handling (bad stream_ids/user_ids)", ({override}) => {
override(stream_events, "process_subscriber_update", noop);
const add_event = {
type: "subscription",
op: "peer_add",
stream_ids: [8888, 9999],
user_ids: [3333, 4444],
};
blueslip.expect("warn", "We have untracked stream_ids: 8888,9999");
blueslip.expect("warn", "We have untracked user_ids");
dispatch(add_event);
blueslip.reset();
const remove_event = {
type: "subscription",
op: "peer_remove",
stream_ids: [8888, 9999],
user_ids: [3333, 4444],
};
blueslip.expect("warn", "We have untracked stream_ids: 8888,9999");
blueslip.expect("warn", "We have untracked user_ids");
dispatch(remove_event);
});
test("stream update", ({override}) => {
const event = event_fixtures.stream__update;
const stub = make_stub();
override(stream_events, "update_property", stub.f);
override(settings_streams, "update_default_streams_table", noop);
override(stream_list, "update_subscribe_to_more_streams_link", noop);
dispatch(event);
assert.equal(stub.num_calls, 1);
const args = stub.get_args("stream_id", "property", "value");
assert.equal(args.stream_id, event.stream_id);
assert.equal(args.property, event.property);
assert.equal(args.value, event.value);
// Dispatch a stream update with property "name" to exercise
// the complete_rerender calls for inbox and recent views.
const name_event = {...event, property: "name", value: "new_name"};
dispatch(name_event);
assert.equal(stub.num_calls, 2);
});
test("stream create", ({override}) => {
assert.equal(sub_store.get(101), undefined);
assert.equal(sub_store.get(102), undefined);
const event = event_fixtures.stream__create;
override(stream_settings_ui, "add_sub_to_table", noop);
override(stream_list, "update_subscribe_to_more_streams_link", noop);
override(overlays, "streams_open", () => true);
dispatch(event);
assert.deepEqual(sub_store.get(101).name, "devel");
assert.deepEqual(sub_store.get(102).name, "test");
});
test("stream delete (normal)", ({override, override_rewire}) => {
const event = event_fixtures.stream__delete;
const devel_sub = make_stream({
stream_id: event.stream_ids[0],
name: "devel",
is_archived: false,
subscribed: false,
});
const test_sub = make_stream({
stream_id: event.stream_ids[1],
name: "test",
is_archived: false,
});
stream_data.add_sub_for_tests(test_sub);
stream_data.add_sub_for_tests(devel_sub);
override_rewire(stream_data, "set_max_channel_width_css_variable", noop);
stream_data.subscribe_myself(devel_sub);
stream_data.set_realm_default_streams(event.stream_ids);
override(settings_streams, "update_default_streams_table", () => {
for (const stream_id of event.stream_ids) {
assert.equal(stream_data.is_default_stream_id(stream_id), false);
}
});
const removed_stream_ids = [];
override(stream_settings_ui, "remove_stream", (stream_id) => {
removed_stream_ids.push(stream_id);
});
override(stream_list, "update_subscribe_to_more_streams_link", noop);
override(unread_ops, "process_read_messages_event", noop);
override(message_events, "remove_messages", noop);
override(user_group_edit, "update_group_permissions_panel_on_losing_stream_access", noop);
dispatch(event);
assert.deepEqual(removed_stream_ids, [event.stream_ids[0], event.stream_ids[1]]);
});
test("stream delete (special streams)", ({override, override_rewire}) => {
const event = event_fixtures.stream__delete;
const devel_sub = make_stream({
stream_id: event.stream_ids[0],
name: "devel",
is_archived: false,
});
const test_sub = make_stream({
stream_id: event.stream_ids[1],
name: "test",
is_archived: false,
});
stream_data.add_sub_for_tests(devel_sub);
stream_data.add_sub_for_tests(test_sub);
override_rewire(stream_data, "set_max_channel_width_css_variable", noop);
stream_data.subscribe_myself(devel_sub);
const removed_stream_ids = [];
override(stream_settings_ui, "remove_stream", (stream_id) => {
removed_stream_ids.push(stream_id);
});
// sanity check data
assert.equal(event.stream_ids.length, 2);
override(realm, "realm_moderation_request_channel_id", event.stream_ids[0]);
override(realm, "realm_new_stream_announcements_stream_id", event.stream_ids[0]);
override(realm, "realm_signup_announcements_stream_id", event.stream_ids[1]);
override(realm, "realm_zulip_update_announcements_stream_id", event.stream_ids[0]);
override(settings_org, "sync_realm_settings", noop);
override(settings_streams, "update_default_streams_table", noop);
override(stream_list, "update_subscribe_to_more_streams_link", noop);
override(unread_ops, "process_read_messages_event", noop);
override(message_events, "remove_messages", noop);
override(user_group_edit, "update_group_permissions_panel_on_losing_stream_access", noop);
dispatch(event);
assert.deepEqual(removed_stream_ids, [event.stream_ids[0], event.stream_ids[1]]);
assert.equal(realm.realm_moderation_request_channel_id, event.stream_ids[0]);
assert.equal(realm.realm_new_stream_announcements_stream_id, event.stream_ids[0]);
assert.equal(realm.realm_signup_announcements_stream_id, event.stream_ids[1]);
assert.equal(realm.realm_zulip_update_announcements_stream_id, event.stream_ids[0]);
});
test("stream delete (stream is selected in compose)", ({override}) => {
override(compose_recipient, "on_compose_select_recipient_update", noop);
const event = event_fixtures.stream__delete;
const devel_sub = make_stream({
stream_id: event.stream_ids[0],
name: "devel",
is_archived: false,
});
const test_sub = make_stream({
stream_id: event.stream_ids[1],
name: "test",
is_archived: false,
});
stream_data.add_sub_for_tests(devel_sub);
stream_data.add_sub_for_tests(test_sub);
compose_state.set_stream_id(event.stream_ids[0]);
const removed_stream_ids = [];
override(stream_settings_ui, "remove_stream", (stream_id) => {
removed_stream_ids.push(stream_id);
});
override(settings_streams, "update_default_streams_table", noop);
override(stream_list, "update_subscribe_to_more_streams_link", noop);
override(unread_ops, "process_read_messages_event", noop);
override(message_events, "remove_messages", noop);
override(user_group_edit, "update_group_permissions_panel_on_losing_stream_access", noop);
dispatch(event);
assert.deepEqual(removed_stream_ids, [event.stream_ids[0], event.stream_ids[1]]);
assert.equal(compose_state.stream_name(), "");
});

896
web/tests/drafts.test.cjs Normal file
View File

@@ -0,0 +1,896 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_banners} = require("./lib/compose_banner.cjs");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {clock, mock_esm, mock_cjs, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const user_pill = mock_esm("../src/user_pill", {get_user_ids: () => []});
const settings_data = mock_esm("../src/settings_data");
const messages_overlay_ui = mock_esm("../src/messages_overlay_ui");
const channel = mock_esm("../src/channel");
const people = zrequire("people");
const compose_state = zrequire("compose_state");
const compose_recipient = zrequire("compose_recipient");
const stream_data = zrequire("stream_data");
const stream_color = zrequire("stream_color");
const {initialize_user_settings} = zrequire("user_settings");
const {set_current_user, set_realm} = zrequire("state_data");
class Clipboard {
on() {}
}
mock_cjs("clipboard", Clipboard);
initialize_user_settings({user_settings: {}});
const REALM_EMPTY_TOPIC_DISPLAY_NAME = "test general chat";
const realm = make_realm({realm_empty_topic_display_name: REALM_EMPTY_TOPIC_DISPLAY_NAME});
set_realm(realm);
const aaron = make_user({
email: "aaron@zulip.com",
user_id: 6,
full_name: "Aaron",
});
const iago = make_user({
email: "iago@zulip.com",
user_id: 2,
full_name: "Iago",
});
const zoe = make_user({
email: "zoe@zulip.com",
user_id: 3,
full_name: "Zoe",
});
set_current_user(aaron);
people.add_active_user(aaron);
people.add_valid_user_id(aaron.user_id);
people.initialize_current_user(aaron.user_id);
people.add_active_user(iago);
people.add_valid_user_id(iago.user_id);
people.add_active_user(zoe);
people.add_valid_user_id(zoe.user_id);
const stream_A = make_stream({
subscribed: false,
name: "A",
stream_id: 1,
});
const stream_B = make_stream({
subscribed: false,
name: "B",
stream_id: 2,
});
const stream_1 = make_stream({
subscribed: false,
name: "stream 1",
stream_id: 30,
color: "c2726a",
});
const stream_2 = make_stream({
subscribed: false,
name: "stream 2",
stream_id: 40,
color: "e2226a",
invite_only: false,
is_web_public: false,
});
stream_data.add_sub_for_tests(stream_A);
stream_data.add_sub_for_tests(stream_B);
stream_data.add_sub_for_tests(stream_1);
stream_data.add_sub_for_tests(stream_2);
const setTimeout_delay = new Set([0, 3000]);
set_global("setTimeout", (f, delay) => {
assert.ok(setTimeout_delay.has(delay));
f();
});
const markdown = mock_esm("../src/markdown", {
render: noop,
contains_backend_only_syntax: () => false,
});
const overlays = mock_esm("../src/overlays", {
open_overlay: noop,
});
mock_esm("../src/rendered_markdown", {
update_elements: noop,
});
const tippy_sel = ".top_left_drafts .unread_count";
let tippy_args;
let tippy_show_called;
let tippy_destroy_called;
mock_esm("tippy.js", {
default(sel, opts) {
assert.equal(sel, tippy_sel);
assert.deepEqual(opts, tippy_args);
return [
{
show() {
tippy_show_called = true;
},
destroy() {
tippy_destroy_called = true;
},
},
];
},
delegate: noop,
});
const {localstorage} = zrequire("localstorage");
const drafts = zrequire("drafts");
const drafts_overlay_ui = zrequire("drafts_overlay_ui");
zrequire("timerender");
const mock_current_timestamp = 1234;
const stream_id = 30;
const draft_1 = {
stream_id,
topic: "topic",
type: "stream",
content: "Test stream message",
updatedAt: mock_current_timestamp,
is_sending_saving: false,
drafts_version: 1,
};
const draft_2 = {
private_message_recipient_ids: [aaron.user_id],
type: "private",
content: "Test direct message",
updatedAt: mock_current_timestamp,
is_sending_saving: false,
drafts_version: 1,
};
const short_msg = {
stream_id,
topic: "topic",
type: "stream",
content: "a",
updatedAt: mock_current_timestamp,
is_sending_saving: false,
drafts_version: 1,
};
function test(label, f) {
run_test(label, (helpers) => {
$(".top_left_drafts").set_find_results(".unread_count", $("<unread-count-stub>"));
$(".compose-drafts-count-container").set_find_results(
".compose-drafts-count",
$("<compose-drafts-count-stub>"),
);
window.localStorage.clear();
f(helpers);
});
}
// There were some buggy drafts that had their topics
// renamed to `undefined` in #23238.
// TODO/compatibility: The next two tests can be deleted
// when we get to delete drafts.fix_drafts_with_undefined_topics.
//
// This test must run before others, so that
// fixed_buggy_drafts and fixed_private_draft_recipient_ids are false.
test("fix buggy drafts", () => {
const buggy_draft = {
stream_id: stream_B.stream_id,
topic: undefined,
type: "stream",
content: "Test stream message",
updatedAt: Date.now(),
};
const draft_with_pm_emails = {
private_message_recipient: "iago@zulip.com,zoe@zulip.com",
reply_to: "iago@zulip.com,zoe@zulip.com",
type: "private",
content: "Test direct message",
updatedAt: Date.now(),
};
// 999 is an invalid user ID not present in the people store.
const draft_with_invalid_recipient = {
private_message_recipient_ids: [iago.user_id, 999],
type: "private",
content: "Test direct message 2",
updatedAt: Date.now(),
is_sending_saving: false,
drafts_version: 1,
};
const draft_with_only_invalid_recipients = {
private_message_recipient_ids: [999],
type: "private",
content: "Test direct message 3",
updatedAt: Date.now(),
is_sending_saving: false,
drafts_version: 1,
};
const ls = localstorage();
ls.set("drafts", {
id1: buggy_draft,
id2: draft_with_pm_emails,
id3: draft_with_invalid_recipient,
id4: draft_with_only_invalid_recipients,
});
const draft_model = drafts.draft_model;
// The draft is fixed in this codepath.
drafts.rename_stream_recipient(
stream_B.stream_id,
"old_topic",
stream_A.stream_id,
"new_topic",
);
const draft = draft_model.getDraft("id1");
assert.equal(draft.stream_id, stream_B.stream_id);
assert.equal(draft.topic, "");
const fixed_draft = draft_model.getDraft("id2");
assert.equal(fixed_draft.private_message_recipient, undefined);
assert.deepEqual(fixed_draft.private_message_recipient_ids, [iago.user_id, zoe.user_id]);
// reply_to field is also removed since it is not present in the
// zod schema.
assert.equal(fixed_draft.reply_to, undefined);
// fix_private_draft_recipient_ids removes invalid user IDs.
const fixed_invalid_recipient_draft = draft_model.getDraft("id3");
assert.deepEqual(fixed_invalid_recipient_draft.private_message_recipient_ids, [iago.user_id]);
const fixed_only_invalid_recipients_draft = draft_model.getDraft("id4");
assert.deepEqual(fixed_only_invalid_recipients_draft.private_message_recipient_ids, []);
});
test("draft_model add", () => {
const draft_model = drafts.draft_model;
const ls = localstorage();
assert.equal(ls.get("draft"), undefined);
const id = draft_model.addDraft(draft_1);
assert.deepEqual(draft_model.getDraft(id), draft_1);
});
test("draft_model edit", () => {
const draft_model = drafts.draft_model;
const ls = localstorage();
assert.equal(ls.get("draft"), undefined);
const id = draft_model.addDraft(draft_1);
assert.deepEqual(draft_model.getDraft(id), draft_1);
draft_model.editDraft(id, draft_2);
assert.deepEqual(draft_model.getDraft(id), draft_2);
});
test("draft_model delete", () => {
const draft_model = drafts.draft_model;
const ls = localstorage();
assert.equal(ls.get("draft"), undefined);
const id = draft_model.addDraft(draft_1);
assert.deepEqual(draft_model.getDraft(id), draft_1);
draft_model.deleteDrafts([id]);
assert.deepEqual(draft_model.getDraft(id), false);
});
test("snapshot_message", ({override}) => {
override(user_pill, "get_user_ids", () => [aaron.user_id]);
mock_banners();
let curr_draft;
function set_compose_state() {
compose_state.set_message_type(curr_draft.type);
compose_state.message_content(curr_draft.content);
if (curr_draft.type === "private") {
compose_state.set_compose_recipient_id(compose_recipient.DIRECT_MESSAGE_ID);
} else {
compose_state.set_stream_id(curr_draft.stream_id);
}
compose_state.topic(curr_draft.topic);
}
compose_state.set_stream_id(stream_1.stream_id);
override(Date, "now", () => mock_current_timestamp);
curr_draft = draft_1;
set_compose_state();
assert.deepEqual(drafts.snapshot_message(), draft_1);
curr_draft = draft_2;
set_compose_state();
assert.deepEqual(drafts.snapshot_message(), draft_2);
curr_draft = short_msg;
set_compose_state();
assert.deepEqual(drafts.snapshot_message(), undefined);
curr_draft = {type: false};
set_compose_state();
assert.equal(drafts.snapshot_message(), undefined);
});
test("initialize", ({override_rewire}) => {
window.addEventListener = (event_name, f) => {
assert.equal(event_name, "beforeunload");
let called = false;
override_rewire(drafts, "update_draft", () => {
called = true;
return 100;
});
f();
assert.ok(called);
};
drafts.initialize();
drafts.initialize_ui();
drafts_overlay_ui.initialize();
});
test("update_draft", ({override}) => {
compose_state.set_message_type(undefined);
let draft_id = drafts.update_draft();
assert.equal(draft_id, undefined);
override(user_pill, "get_user_ids", () => [aaron.user_id]);
compose_state.set_message_type("private");
compose_state.message_content("dummy content");
tippy_args = {
content: "translated: Saved as draft",
arrow: true,
placement: "right",
};
tippy_show_called = false;
tippy_destroy_called = false;
override(Date, "now", () => 5);
override(Math, "random", () => 2);
draft_id = drafts.update_draft();
assert.equal(draft_id, "5-2");
assert.ok(tippy_show_called);
assert.ok(tippy_destroy_called);
override(Date, "now", () => 6);
compose_state.message_content("dummy content edited once");
tippy_show_called = false;
tippy_destroy_called = false;
draft_id = drafts.update_draft();
assert.equal(draft_id, "5-2");
assert.ok(tippy_show_called);
assert.ok(tippy_destroy_called);
override(Date, "now", () => 7);
// message contents not edited
tippy_show_called = false;
tippy_destroy_called = false;
draft_id = drafts.update_draft();
assert.equal(draft_id, "5-2");
assert.ok(!tippy_show_called);
assert.ok(!tippy_destroy_called);
override(Date, "now", () => 8);
compose_state.message_content("dummy content edited a second time");
tippy_show_called = false;
tippy_destroy_called = false;
draft_id = drafts.update_draft({no_notify: true});
assert.equal(draft_id, "5-2");
assert.ok(!tippy_show_called);
assert.ok(!tippy_destroy_called);
});
test("rename_stream_recipient", () => {
const draft_1 = {
stream_id: stream_A.stream_id,
topic: "a",
type: "stream",
content: "Test stream message",
updatedAt: Date.now(),
};
const draft_2 = {
stream_id: stream_A.stream_id,
topic: "b",
type: "stream",
content: "Test stream message",
updatedAt: Date.now(),
};
const draft_3 = {
stream_id: stream_B.stream_id,
topic: "a",
type: "stream",
content: "Test stream message",
updatedAt: Date.now(),
};
const draft_4 = {
stream_id: stream_B.stream_id,
topic: "c",
type: "stream",
content: "Test stream message",
updatedAt: Date.now(),
};
const data = {id1: draft_1, id2: draft_2, id3: draft_3, id4: draft_4};
const ls = localstorage();
ls.set("drafts", data);
const draft_model = drafts.draft_model;
function assert_draft(draft_id, stream_id, topic_name) {
const draft = draft_model.getDraft(draft_id);
assert.equal(draft.topic, topic_name);
assert.equal(draft.stream_id, stream_id);
}
// There are no drafts in B>b, so moving messages from there doesn't change drafts
drafts.rename_stream_recipient(stream_B.stream_id, "b", undefined, "c");
assert_draft("id1", stream_A.stream_id, "a");
assert_draft("id2", stream_A.stream_id, "b");
assert_draft("id3", stream_B.stream_id, "a");
assert_draft("id4", stream_B.stream_id, "c");
// Update with both stream and topic changes Bc -> Aa
drafts.rename_stream_recipient(stream_B.stream_id, "c", stream_A.stream_id, "a");
assert_draft("id1", stream_A.stream_id, "a");
assert_draft("id2", stream_A.stream_id, "b");
assert_draft("id3", stream_B.stream_id, "a");
assert_draft("id4", stream_A.stream_id, "a");
// Update with only stream change Aa -> Ba
drafts.rename_stream_recipient(stream_A.stream_id, "a", stream_B.stream_id, undefined);
assert_draft("id1", stream_B.stream_id, "a");
assert_draft("id2", stream_A.stream_id, "b");
assert_draft("id3", stream_B.stream_id, "a");
assert_draft("id4", stream_B.stream_id, "a");
// Update with only topic change, affecting three messages
drafts.rename_stream_recipient(stream_B.stream_id, "a", undefined, "e");
assert_draft("id1", stream_B.stream_id, "e");
assert_draft("id2", stream_A.stream_id, "b");
assert_draft("id3", stream_B.stream_id, "e");
assert_draft("id4", stream_B.stream_id, "e");
});
test("delete_all_drafts", () => {
const draft_model = drafts.draft_model;
const ls = localstorage();
const data = {draft_1, draft_2, short_msg};
ls.set("drafts", data);
assert.deepEqual(draft_model.get(), data);
drafts.delete_all_drafts();
assert.deepEqual(draft_model.get(), {});
});
test("format_drafts", ({override, mock_template}) => {
override(settings_data, "using_dark_theme", () => false);
function feb12() {
return new Date(1549958107000); // 2/12/2019 07:55:07 AM (UTC+0)
}
function date(offset) {
return feb12().setDate(offset);
}
const draft_1 = {
topic: "topic",
type: "stream",
content: "Test stream message",
stream_id: 30,
updatedAt: feb12().getTime(),
is_sending_saving: false,
drafts_version: 1,
};
const draft_2 = {
private_message_recipient_ids: [aaron.user_id],
type: "private",
content: "Test direct message",
updatedAt: date(-1),
is_sending_saving: false,
drafts_version: 1,
};
const draft_3 = {
topic: "topic",
type: "stream",
stream_id: 40,
content: "Test stream message 2",
updatedAt: date(-10),
is_sending_saving: false,
drafts_version: 1,
};
const draft_4 = {
private_message_recipient_ids: [iago.user_id],
type: "private",
content: "Test direct message 2",
updatedAt: date(-5),
is_sending_saving: false,
drafts_version: 1,
};
const draft_5 = {
private_message_recipient_ids: [zoe.user_id, iago.user_id],
type: "private",
content: "Test direct message 3",
updatedAt: date(-2),
is_sending_saving: false,
drafts_version: 1,
};
const draft_6 = {
topic: "",
type: "stream",
stream_id: 40,
content: "Test stream message 3",
updatedAt: date(-11),
is_sending_saving: false,
drafts_version: 1,
};
const draft_7 = {
private_message_recipient_ids: [],
type: "private",
content: "Test direct message 4",
updatedAt: date(-12),
is_sending_saving: false,
drafts_version: 1,
};
const expected = [
{
draft_id: "id1",
is_stream: true,
stream_name: stream_1.name,
stream_id: 30,
recipient_bar_color: stream_color.get_recipient_bar_color(stream_1.color),
stream_privacy_icon_color: stream_color.get_stream_privacy_icon_color(stream_1.color),
topic_display_name: "topic",
is_empty_string_topic: false,
raw_content: "Test stream message",
time_stamp: "7:55 AM",
invite_only: stream_1.invite_only,
is_web_public: stream_1.is_web_public,
is_sending_saving: false,
},
{
draft_id: "id2",
is_dm_with_self: true,
is_stream: false,
has_recipient_data: true,
recipients: "Aaron",
raw_content: "Test direct message",
time_stamp: "Jan 30",
is_sending_saving: false,
},
{
draft_id: "id5",
is_dm_with_self: false,
is_stream: false,
recipients: "Iago, Zoe",
has_recipient_data: true,
raw_content: "Test direct message 3",
time_stamp: "Jan 29",
is_sending_saving: false,
},
{
draft_id: "id4",
is_dm_with_self: false,
is_stream: false,
recipients: "Iago",
has_recipient_data: true,
raw_content: "Test direct message 2",
time_stamp: "Jan 26",
is_sending_saving: false,
},
{
draft_id: "id3",
is_stream: true,
stream_name: stream_2.name,
stream_id: 40,
recipient_bar_color: stream_color.get_recipient_bar_color(stream_2.color),
stream_privacy_icon_color: stream_color.get_stream_privacy_icon_color(stream_2.color),
topic_display_name: "topic",
is_empty_string_topic: false,
raw_content: "Test stream message 2",
time_stamp: "Jan 21",
invite_only: stream_2.invite_only,
is_web_public: stream_2.is_web_public,
is_sending_saving: false,
},
{
draft_id: "id6",
is_stream: true,
stream_name: stream_2.name,
stream_id: 40,
recipient_bar_color: stream_color.get_recipient_bar_color(stream_2.color),
stream_privacy_icon_color: stream_color.get_stream_privacy_icon_color(stream_2.color),
topic_display_name: REALM_EMPTY_TOPIC_DISPLAY_NAME,
is_empty_string_topic: true,
raw_content: "Test stream message 3",
time_stamp: "Jan 20",
invite_only: stream_2.invite_only,
is_web_public: stream_2.is_web_public,
is_sending_saving: false,
},
{
draft_id: "id7",
is_stream: false,
has_recipient_data: false,
recipients: "",
raw_content: "Test direct message 4",
time_stamp: "Jan 19",
is_sending_saving: false,
},
];
const draft_model = drafts.draft_model;
const ls = localstorage();
const data = {
id1: draft_1,
id2: draft_2,
id3: draft_3,
id4: draft_4,
id5: draft_5,
id6: draft_6,
id7: draft_7,
};
ls.set("drafts", data);
assert.deepEqual(draft_model.get(), data);
override(realm, "realm_topics_policy", "disable_empty_topic");
expected[5].topic_display_name = "translated: No topic entered";
expected[5].is_empty_string_topic = true;
assert.deepEqual(draft_model.get(), data);
clock.setSystemTime(1549958107000);
override(user_pill, "get_user_ids", () => []);
compose_state.set_message_type("private");
mock_template("draft_table_body.hbs", false, (data) => {
// Tests formatting and time-sorting of drafts
assert.deepEqual(data.context.narrow_drafts, []);
assert.deepEqual(data.context.other_drafts, expected);
assert.ok(data);
return "<draft table stub>";
});
override(messages_overlay_ui, "set_initial_element", noop);
override(messages_overlay_ui, "get_and_clear_pending_restore_element_id", () => undefined);
$.set_results(".drafts-list", []);
$.set_results(".drafts-tab-pane .overlay-message-row", []);
$.set_results(".draft-selection-checkbox", []);
drafts_overlay_ui.launch();
});
test("filter_drafts", ({override, mock_template}) => {
override(settings_data, "using_dark_theme", () => true);
function feb12() {
return new Date(1549958107000); // 2/12/2019 07:55:07 AM (UTC+0)
}
function date(offset) {
return feb12().setDate(offset);
}
const stream_draft_1 = {
topic: "topic",
type: "stream",
content: "Test stream message",
stream_id: 30,
updatedAt: feb12().getTime(),
is_sending_saving: false,
drafts_version: 1,
};
const pm_draft_1 = {
private_message_recipient_ids: [aaron.user_id],
type: "private",
content: "Test direct message",
updatedAt: date(-1),
is_sending_saving: false,
drafts_version: 1,
};
const stream_draft_2 = {
topic: "topic",
type: "stream",
stream_id: 40,
content: "Test stream message 2",
updatedAt: date(-10),
is_sending_saving: false,
drafts_version: 1,
};
const pm_draft_2 = {
private_message_recipient_ids: [aaron.user_id],
type: "private",
content: "Test direct message 2",
updatedAt: date(-5),
is_sending_saving: false,
drafts_version: 1,
};
const pm_draft_3 = {
private_message_recipient_ids: [aaron.user_id],
type: "private",
content: "Test direct message 3",
updatedAt: date(-2),
is_sending_saving: false,
drafts_version: 1,
};
const expected_pm_drafts = [
{
draft_id: "id2",
is_dm_with_self: true,
is_stream: false,
has_recipient_data: true,
recipients: "Aaron",
raw_content: "Test direct message",
time_stamp: "Jan 30",
is_sending_saving: false,
},
{
draft_id: "id5",
is_dm_with_self: true,
is_stream: false,
has_recipient_data: true,
recipients: "Aaron",
raw_content: "Test direct message 3",
time_stamp: "Jan 29",
is_sending_saving: false,
},
{
draft_id: "id4",
is_dm_with_self: true,
is_stream: false,
has_recipient_data: true,
recipients: "Aaron",
raw_content: "Test direct message 2",
time_stamp: "Jan 26",
is_sending_saving: false,
},
];
const expected_other_drafts = [
{
draft_id: "id1",
is_stream: true,
stream_name: stream_1.name,
stream_id: 30,
recipient_bar_color: stream_color.get_recipient_bar_color(stream_1.color),
stream_privacy_icon_color: stream_color.get_stream_privacy_icon_color(stream_1.color),
topic_display_name: "topic",
is_empty_string_topic: false,
raw_content: "Test stream message",
time_stamp: "7:55 AM",
invite_only: stream_1.invite_only,
is_web_public: stream_1.is_web_public,
is_sending_saving: false,
},
{
draft_id: "id3",
is_stream: true,
stream_name: stream_2.name,
stream_id: 40,
recipient_bar_color: stream_color.get_recipient_bar_color(stream_2.color),
stream_privacy_icon_color: stream_color.get_stream_privacy_icon_color(stream_2.color),
topic_display_name: "topic",
is_empty_string_topic: false,
raw_content: "Test stream message 2",
time_stamp: "Jan 21",
invite_only: stream_2.invite_only,
is_web_public: stream_2.is_web_public,
is_sending_saving: false,
},
];
const draft_model = drafts.draft_model;
const ls = localstorage();
const data = {
id1: stream_draft_1,
id2: pm_draft_1,
id3: stream_draft_2,
id4: pm_draft_2,
id5: pm_draft_3,
};
ls.set("drafts", data);
assert.deepEqual(draft_model.get(), data);
clock.setSystemTime(1549958107000);
mock_template("draft_table_body.hbs", false, (data) => {
// Tests splitting up drafts by current narrow.
assert.deepEqual(data.context.narrow_drafts, expected_pm_drafts);
assert.deepEqual(data.context.other_drafts, expected_other_drafts);
return "<draft table stub>";
});
override(messages_overlay_ui, "set_initial_element", noop);
override(messages_overlay_ui, "get_and_clear_pending_restore_element_id", () => undefined);
override(user_pill, "get_user_ids", () => [aaron.user_id]);
compose_state.set_message_type("private");
$.set_results(".drafts-list", []);
$.set_results(".drafts-tab-pane .overlay-message-row", []);
$.set_results(".draft-selection-checkbox", []);
drafts_overlay_ui.launch();
});
test("server_rendering_for_backend_only_syntax", ({override, mock_template}) => {
override(settings_data, "using_dark_theme", () => false);
const now = Date.now();
const draft_with_image = {
topic: "topic",
type: "stream",
content: "Look at this screenshot: https://user-uploads.zulipdev.org/upload/image.png",
stream_id: 30,
updatedAt: now,
is_sending_saving: false,
drafts_version: 1,
};
const draft_without_image = {
topic: "topic",
type: "stream",
content: "This is a plain text draft",
stream_id: 30,
updatedAt: now - 1000,
is_sending_saving: false,
drafts_version: 1,
};
const draft_model = drafts.draft_model;
const ls = localstorage();
ls.set("drafts", {id1: draft_with_image, id2: draft_without_image});
assert.deepEqual(draft_model.get(), {id1: draft_with_image, id2: draft_without_image});
override(
markdown,
"contains_backend_only_syntax",
(content) => content === draft_with_image.content,
);
const locally_rendered_content =
'<p>Look at this screenshot: <a href="https://user-uploads.zulipdev.org/upload/image.png">https://user-uploads.zulipdev.org/upload/image.png</a></p>';
const server_rendered_content =
'<p>Look at this screenshot: <a href="https://user-uploads.zulipdev.org/upload/image.png" target="_blank" rel="noopener noreferrer" title="https://user-uploads.zulipdev.org/upload/image.png"><img src="/thumbnail?url=user_uploads%2Fimage.png&amp;size=thumbnail"></a></p>';
let post_calls = 0;
const $content_element = $.create('[data-draft-id="id1"] .message_content');
$content_element.html(locally_rendered_content);
override(overlays, "drafts_open", () => true);
mock_template("draft_table_body.hbs", false, () => "<draft table stub>");
override(messages_overlay_ui, "set_initial_element", noop);
override(messages_overlay_ui, "get_and_clear_pending_restore_element_id", () => undefined);
// These selectors are accessed during render_widgets() and
// update_bulk_delete_ui() inside launch().
$.set_results(".drafts-list", []);
$.set_results(".drafts-tab-pane .overlay-message-row", []);
$.set_results(".draft-selection-checkbox", []);
override(channel, "post", (payload) => {
post_calls += 1;
assert.equal(payload.url, "/json/messages/render");
assert.equal(payload.data.content, draft_with_image.content);
const resp = {
msg: "",
result: "success",
rendered: server_rendered_content,
};
payload.success(resp);
assert.equal($content_element.html(), server_rendered_content);
});
drafts_overlay_ui.launch();
// Only the draft with the image triggered a server render request;
// the plain text draft did not.
assert.equal(post_calls, 1);
});

803
web/tests/echo.test.cjs Normal file
View File

@@ -0,0 +1,803 @@
"use strict";
const assert = require("node:assert/strict");
const {make_user_group} = require("./lib/example_group.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {clock, mock_esm, zrequire} = require("./lib/namespace.cjs");
const {make_stub} = require("./lib/stub.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const browser_history = mock_esm("../src/browser_history");
const compose_notifications = mock_esm("../src/compose_notifications");
const hash_util = mock_esm("../src/hash_util");
const markdown = mock_esm("../src/markdown");
const message_lists = mock_esm("../src/message_lists");
const message_events_util = mock_esm("../src/message_events_util");
const pm_list = mock_esm("../src/pm_list");
const stream_list = mock_esm("../src/stream_list");
let disparities = [];
mock_esm("../src/message_live_update", {
update_message_in_all_views() {},
});
mock_esm("../src/sent_messages", {
mark_disparity(local_id) {
disparities.push(local_id);
},
report_event_received() {},
});
const message_store = mock_esm("../src/message_store", {
get: () => ({failed_request: true}),
update_booleans() {},
maybe_update_raw_content() {},
update_message_content(message, new_content) {
message.content = new_content;
},
convert_raw_message_to_message_with_booleans() {},
});
message_lists.current = {
view: {
rerender_messages: noop,
change_message_id: noop,
},
data: {
filter: {
can_apply_locally() {
return true;
},
has_exactly_channel_topic_operators() {
return true;
},
adjust_with_operand_to_message: noop,
terms: noop,
},
},
change_message_id: noop,
add_messages: noop,
remove_and_rerender: noop,
};
const home_msg_list = {
view: {
rerender_messages: noop,
change_message_id: noop,
},
data: {
filter: {
can_apply_locally() {
return true;
},
},
},
preserver_rendered_state: true,
change_message_id: noop,
add_messages: noop,
remove_and_rerender: noop,
};
message_lists.all_rendered_message_lists = () => [home_msg_list, message_lists.current];
message_lists.non_rendered_data = () => [];
const echo = zrequire("echo");
const echo_state = zrequire("echo_state");
const people = zrequire("people");
const pm_conversations = zrequire("pm_conversations");
const {set_current_user} = zrequire("state_data");
const stream_data = zrequire("stream_data");
const stream_topic_history = zrequire("stream_topic_history");
const current_user = {};
set_current_user(current_user);
const general_sub = make_stream({
stream_id: 101,
name: "general",
subscribed: true,
});
stream_data.add_sub_for_tests(general_sub);
run_test("process_from_server for un-echoed messages", () => {
const waiting_for_ack = new Map();
const server_messages = [
{
local_id: "100.1",
},
];
echo_state._patch_waiting_for_ack(waiting_for_ack);
const non_echo_messages = echo.process_from_server(server_messages);
assert.deepEqual(non_echo_messages, server_messages);
});
run_test("process_from_server for differently rendered messages", ({override}) => {
let messages_to_rerender = [];
override(home_msg_list.view, "rerender_messages", (msgs) => {
messages_to_rerender = msgs;
});
// Test that we update all the booleans and the content of the message
// in local echo.
const old_value = "old_value";
const new_value = "new_value";
const waiting_for_ack = new Map([
[
"100.1",
{
content: "<p>A client rendered message</p>",
timestamp: old_value,
is_me_message: old_value,
submessages: old_value,
topic_links: old_value,
},
],
]);
const server_messages = [
{
local_id: "100.1",
content: "<p>A server rendered message</p>",
timestamp: new_value,
is_me_message: new_value,
submessages: new_value,
topic_links: new_value,
},
];
echo_state._patch_waiting_for_ack(waiting_for_ack);
disparities = [];
const non_echo_messages = echo.process_from_server(server_messages);
assert.deepEqual(non_echo_messages, []);
assert.equal(disparities.length, 1);
assert.deepEqual(messages_to_rerender, [
{
content: server_messages[0].content,
timestamp: new_value,
is_me_message: new_value,
submessages: new_value,
topic_links: new_value,
},
]);
});
run_test("process_from_server for messages to add to narrow", ({override}) => {
let messages_to_add_to_narrow = [];
override(message_lists.current.data.filter, "can_apply_locally", () => false);
override(message_events_util, "maybe_add_narrowed_messages", (msgs, msg_list) => {
messages_to_add_to_narrow = msgs;
assert.equal(msg_list, message_lists.current);
});
const old_value = "old_value";
const new_value = "new_value";
const waiting_for_ack = new Map([
[
"100.1",
{
content: "<p>rendered message</p>",
timestamp: old_value,
is_me_message: old_value,
submessages: old_value,
topic_links: old_value,
},
],
]);
const server_messages = [
{
local_id: "100.1",
content: "<p>rendered message</p>",
timestamp: new_value,
is_me_message: new_value,
submessages: new_value,
topic_links: new_value,
},
];
echo_state._patch_waiting_for_ack(waiting_for_ack);
const non_echo_messages = echo.process_from_server(server_messages);
assert.deepEqual(non_echo_messages, []);
assert.deepEqual(messages_to_add_to_narrow, [
{
content: server_messages[0].content,
timestamp: new_value,
is_me_message: new_value,
submessages: new_value,
topic_links: new_value,
},
]);
});
run_test("build_display_recipient", ({override}) => {
override(current_user, "user_id", 123);
const params = {
realm_users: [
{
user_id: 123,
full_name: "Iago",
email: "iago@zulip.com",
},
{
email: "cordelia@zulip.com",
full_name: "Cordelia",
user_id: 21,
},
],
};
const user_group_params = {
realm_user_groups: [
make_user_group({
is_system_group: true,
members: [123, 21],
}),
],
};
params.realm_non_active_users = [];
params.cross_realm_bots = [];
people.initialize(current_user.user_id, params, user_group_params);
let message = {
type: "stream",
stream_id: general_sub.stream_id,
sender_email: "iago@zulip.com",
sender_full_name: "Iago",
sender_id: 123,
};
let display_recipient = echo.build_display_recipient(message);
assert.equal(display_recipient, "general");
message = {
type: "private",
to_user_ids: "21",
private_message_recipient: "cordelia@zulip.com",
sender_email: "iago@zulip.com",
sender_full_name: "Iago",
sender_id: 123,
};
display_recipient = echo.build_display_recipient(message);
assert.equal(display_recipient.length, 2);
let iago = display_recipient.find((recipient) => recipient.email === "iago@zulip.com");
assert.equal(iago.full_name, "Iago");
assert.equal(iago.id, 123);
const cordelia = display_recipient.find(
(recipient) => recipient.email === "cordelia@zulip.com",
);
assert.equal(cordelia.full_name, "Cordelia");
assert.equal(cordelia.id, 21);
message = {
type: "private",
to_user_ids: "123",
private_message_recipient: "iago@zulip.com",
sender_email: "iago@zulip.com",
sender_full_name: "Iago",
sender_id: 123,
};
display_recipient = echo.build_display_recipient(message);
assert.equal(display_recipient.length, 1);
iago = display_recipient.find((recipient) => recipient.email === "iago@zulip.com");
assert.equal(iago.full_name, "Iago");
assert.equal(iago.id, 123);
});
run_test("update_message_lists", () => {
home_msg_list.view = {};
const stub = make_stub();
const view_stub = make_stub();
home_msg_list.change_message_id = stub.f;
home_msg_list.view.change_message_id = view_stub.f;
echo.update_message_lists({old_id: 401, new_id: 402});
assert.equal(stub.num_calls, 1);
const args = stub.get_args("old", "new");
assert.equal(args.old, 401);
assert.equal(args.new, 402);
assert.equal(view_stub.num_calls, 1);
const view_args = view_stub.get_args("old", "new");
assert.equal(view_args.old, 401);
assert.equal(view_args.new, 402);
});
run_test("insert_local_message streams", ({override}) => {
const fake_now = 555;
clock.setSystemTime(new Date(fake_now * 1000));
const local_id_float = 101.01;
let render_called = false;
let get_topic_links_called = false;
let insert_message_called = false;
override(markdown, "render", () => {
render_called = true;
});
override(markdown, "get_topic_links", () => {
get_topic_links_called = true;
});
const insert_new_messages = (message_data) => {
const [message] = message_data.raw_messages;
assert.equal(message.display_recipient, "general");
assert.equal(message.timestamp, fake_now);
assert.equal(message.sender_email, "iago@zulip.com");
assert.equal(message.sender_full_name, "Iago");
assert.equal(message.sender_id, 123);
insert_message_called = true;
return [message];
};
const message_request = {
type: "stream",
stream_id: general_sub.stream_id,
topic: "important note",
sender_email: "iago@zulip.com",
sender_full_name: "Iago",
sender_id: 123,
};
echo.insert_local_message(message_request, local_id_float, insert_new_messages);
assert.ok(render_called);
assert.ok(get_topic_links_called);
assert.ok(insert_message_called);
});
run_test("insert_local_message direct message", ({override}) => {
const local_id_float = 102.01;
override(current_user, "user_id", 123);
const params = {
realm_users: [
{
user_id: 123,
full_name: "Iago",
email: "iago@zulip.com",
},
{
email: "cordelia@zulip.com",
full_name: "Cordelia",
user_id: 21,
},
],
};
const user_group_params = {
realm_user_groups: [
make_user_group({
is_system_group: true,
members: [123, 21],
}),
],
};
params.realm_non_active_users = [];
params.cross_realm_bots = [];
people.init();
people.initialize(current_user.user_id, params, user_group_params);
let render_called = false;
let insert_message_called = false;
const insert_new_messages = (message_data) => {
const [message] = message_data.raw_messages;
assert.equal(message.display_recipient.length, 2);
insert_message_called = true;
return [message];
};
override(markdown, "render", () => {
render_called = true;
});
override(markdown, "get_topic_links", () => []);
const message_request = {
private_message_recipient: "cordelia@zulip.com",
to_user_ids: "21",
type: "private",
sender_email: "iago@zulip.com",
sender_full_name: "Iago",
sender_id: 123,
};
echo.insert_local_message(message_request, local_id_float, insert_new_messages);
assert.ok(render_called);
assert.ok(insert_message_called);
});
run_test("edit_locally recomputes content-driven booleans", ({override}) => {
// A content edit that drops a personal mention should immediately
// clear the message's mention booleans from the locally rendered
// flags, rather than leaving them stale until the server event
// arrives (which would briefly miscolor the message).
override(markdown, "render", () => ({
content: "<p>edited, no longer mentions me</p>",
flags: [],
is_me_message: false,
}));
override(stream_list, "update_streams_sidebar", noop);
override(pm_list, "update_private_messages", noop);
override(message_lists, "all_rendered_message_lists", () => [
{view: {rerender_messages: noop}},
]);
let update_booleans_args;
override(message_store, "update_booleans", (message, flags) => {
update_booleans_args = {message, flags};
message.mentioned = flags.includes("mentioned");
message.mentioned_me_directly = flags.includes("mentioned");
});
const message = {
id: 42,
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
raw_content: "@**me** original",
content: "<p>original</p>",
mentioned: true,
mentioned_me_directly: true,
};
echo.edit_locally(message, {raw_content: "edited, no longer mentions me"});
assert.equal(update_booleans_args.message, message);
assert.deepEqual(update_booleans_args.flags, []);
assert.equal(message.mentioned, false);
assert.equal(message.mentioned_me_directly, false);
});
run_test("test reify_message_id", ({override}) => {
const local_id_float = 103.01;
override(markdown, "render", noop);
override(markdown, "get_topic_links", noop);
override(hash_util, "search_terms_to_hash", noop);
override(browser_history, "update_current_history_state_data", noop);
const message_request = {
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
sender_email: "iago@zulip.com",
sender_full_name: "Iago",
sender_id: 123,
draft_id: 100,
};
echo.insert_local_message(message_request, local_id_float, (message_data) => {
const messages = message_data.raw_messages;
messages.map((message) => echo.track_local_message(message));
return messages;
});
let message_store_reify_called = false;
let notifications_reify_called = false;
override(message_store, "reify_message_id", () => {
message_store_reify_called = true;
});
override(compose_notifications, "reify_message_id", () => {
notifications_reify_called = true;
});
echo.reify_message_id(local_id_float.toString(), 110);
assert.ok(message_store_reify_called);
assert.ok(notifications_reify_called);
const history = stream_topic_history.find_or_create(general_sub.stream_id);
assert.equal(history.max_message_id, 110);
assert.equal(history.topics.get("test").message_id, 110);
});
function make_spinner_row() {
// A stand-in for the failed-message $row. The retry-spinner show/hide
// helpers look up ".refresh-failed-message" under it and toggle its
// "rotating" class.
const $row = $.create("failed-message-row-stub");
$row.set_find_results(".refresh-failed-message", $.create("refresh-failed-message-stub"));
return $row;
}
run_test("resend success clears the failed flag", ({override}) => {
const stored_messages = new Map();
override(message_store, "get", (id) => stored_messages.get(id));
const local_id = "300.01";
const server_id = 310;
const message = {
id: Number.parseFloat(local_id),
local_id,
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
raw_content: "retry me",
content: "<p>retry me</p>",
locally_echoed: true,
failed_request: true,
};
// The original send truly failed, so the message is still keyed under its
// local id, waiting to be retried.
stored_messages.set(message.id, message);
const on_send_message_success = (_sent_message, data) => {
// Mirror the store re-keying that compose.send_message_success ->
// echo.reify_message_id performs once the server acks the resend.
stored_messages.delete(message.id);
message.id = data.id;
message.locally_echoed = false;
stored_messages.set(data.id, message);
};
const send_message = (_msg, on_success) => {
on_success({id: server_id});
};
echo.resend_message(message, make_spinner_row(), {on_send_message_success, send_message});
assert.equal(message.failed_request, false);
assert.equal(stored_messages.get(server_id), message);
});
run_test("resend gives the success path a pre-send snapshot", ({override}) => {
// The resent message's event can beat its send response, and
// process_from_server reifies on arrival -- converting the message in
// place, so it no longer describes what we sent.
const stored_messages = new Map();
override(message_store, "get", (id) => stored_messages.get(id));
override(compose_notifications, "reify_message_id", noop);
override(hash_util, "search_terms_to_hash", noop);
override(browser_history, "update_current_history_state_data", noop);
override(message_lists, "all_rendered_message_lists", () => [
{
view: {rerender_messages: noop, change_message_id: noop},
data: {filter: {can_apply_locally: () => true}},
change_message_id: noop,
add_messages: noop,
},
]);
const local_id = "320.01";
const server_id = 330;
const message = {
id: Number.parseFloat(local_id),
local_id,
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
raw_content: "retry me",
content: "<p>retry me</p>",
draft_id: "draft-320",
locally_echoed: true,
failed_request: true,
};
stored_messages.set(message.id, message);
echo_state.set_message_waiting_for_id(local_id, message);
echo_state._patch_waiting_for_ack(new Map([[local_id, message]]));
override(message_store, "reify_message_id", ({old_id, new_id}) => {
// Mirror the real conversion: re-key, and drop the local-echo fields.
delete message.draft_id;
stored_messages.delete(old_id);
stored_messages.set(new_id, message);
});
let sent_message;
const on_send_message_success = (msg) => {
sent_message = msg;
};
const send_message = (_request, on_success) => {
echo.process_from_server([{id: server_id, local_id, content: message.content}]);
on_success({id: server_id});
};
echo.resend_message(message, make_spinner_row(), {on_send_message_success, send_message});
// Confirm the ordering under test: reification ran before the response.
assert.equal(message.draft_id, undefined);
assert.equal(message.locally_echoed, false);
assert.equal(sent_message.draft_id, "draft-320");
assert.equal(sent_message.locally_echoed, true);
});
run_test("resend error re-marks a present message as failed", ({override}) => {
// When the resend POST fails while the message is still in the store, the
// error handler surfaces the failure by setting failed_request back to true.
const stored_messages = new Map();
override(message_store, "get", (id) => stored_messages.get(id));
const message = {
id: 280,
local_id: "280.01",
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
raw_content: "retry me",
content: "<p>retry me</p>",
locally_echoed: true,
failed_request: false,
};
stored_messages.set(message.id, message);
const send_message = (_msg, _on_success, on_error) => {
on_error("error response", "");
};
echo.resend_message(message, make_spinner_row(), {on_send_message_success: noop, send_message});
assert.equal(message.failed_request, true);
});
run_test("resend doesn't crash when the message was already reconciled", ({override}) => {
// The original send appeared to fail on the client but had actually
// reached the server. The user resent it; then the original's get-events
// delivery arrived and reconciled the local echo to the real server id,
// consuming the local id. By the time the resend's response arrives,
// reify_message_id early-returns (the local id is gone), so the resent
// message is never stored under the id the response reports, and
// failed_message_success must tolerate that missing entry.
const stored_messages = new Map();
override(message_store, "get", (id) => stored_messages.get(id));
const local_id = "250.01";
const reconciled_id = 260; // server id the message was reconciled to
// The resend reached the server too and, absent server-side
// deduplication, created a second message whose id its response reports.
const resend_response_id = 261;
// The message has already been reconciled: keyed under its real server id,
// no longer locally echoed, and no longer failed.
const message = {
id: reconciled_id,
local_id,
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
raw_content: "hello world",
content: "<p>hello world</p>",
locally_echoed: false,
failed_request: false,
};
stored_messages.set(reconciled_id, message);
const on_send_message_success = (msg, data) => {
// compose.send_message_success calls echo.reify_message_id, which
// early-returns here because the local id was already consumed.
echo.reify_message_id(msg.local_id, data.id);
};
const send_message = (_msg, on_success) => {
on_success({id: resend_response_id});
};
echo.resend_message(message, make_spinner_row(), {on_send_message_success, send_message});
// The resend's success path only reconciles the message it resent; it does
// not store a message under the response's id. (A second message created
// on the server would instead arrive via its own get-events delivery.) So
// the store has no entry there, and the reconciled message stays un-failed.
assert.equal(stored_messages.get(resend_response_id), undefined);
assert.equal(message.failed_request, false);
});
run_test("resend error on a message removed from the store doesn't crash", ({override}) => {
// If the resend POST fails after the message was removed from the store
// (e.g. it was deleted while the resend was in flight), message_send_error
// must tolerate the missing entry rather than dereference undefined.
const stored_messages = new Map();
override(message_store, "get", (id) => stored_messages.get(id));
const message = {
id: 270,
local_id: "270.01",
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
raw_content: "gone",
content: "<p>gone</p>",
locally_echoed: true,
failed_request: false,
};
// The message is no longer in the store (stored_messages is empty).
const send_message = (_msg, _on_success, on_error) => {
on_error("error response", "");
};
// If message_send_error threw on the missing store entry, resend_message
// would propagate it and fail this test.
echo.resend_message(message, make_spinner_row(), {
on_send_message_success: noop,
send_message,
});
// The detached message was left untouched, since there was no stored entry
// to mark as failed.
assert.equal(message.failed_request, false);
});
run_test("abort_message clears the failed echo from echo_state", ({override}) => {
const local_id = "77.01";
const message = {
id: 77,
local_id,
type: "stream",
stream_id: general_sub.stream_id,
topic: "test",
locally_echoed: true,
failed_request: true,
};
echo_state._patch_waiting_for_ack(new Map());
echo.track_local_message(message);
assert.equal(echo_state.get_message_waiting_for_id(local_id), message);
assert.equal(echo_state.get_message_waiting_for_ack(local_id), message);
const removed_ids = [];
override(message_lists.current, "remove_and_rerender", (ids) => {
removed_ids.push(...ids);
});
echo.abort_message(message);
assert.deepEqual(removed_ids, [77]);
assert.equal(echo_state.get_message_waiting_for_id(local_id), undefined);
assert.equal(echo_state.get_message_waiting_for_ack(local_id), undefined);
});
run_test("reify_message_id counts a sent direct message", ({override}) => {
// A sent DM is counted only once acked (on reification), mirroring how
// stream messages are added to stream_topic_history there.
const local_id_float = 104.01;
const iago = {user_id: 123, full_name: "Iago", email: "iago@zulip.com"};
const cordelia = {user_id: 21, full_name: "Cordelia", email: "cordelia@zulip.com"};
override(current_user, "user_id", iago.user_id);
const people_params = {
realm_users: [iago, cordelia],
realm_non_active_users: [],
cross_realm_bots: [],
};
const user_group_params = {
realm_user_groups: [
make_user_group({is_system_group: true, members: [iago.user_id, cordelia.user_id]}),
],
};
people.init();
people.initialize(current_user.user_id, people_params, user_group_params);
override(markdown, "render", noop);
override(markdown, "get_topic_links", () => []);
override(message_store, "reify_message_id", noop);
override(compose_notifications, "reify_message_id", noop);
const message_request = {
private_message_recipient: cordelia.email,
to_user_ids: cordelia.user_id.toString(),
type: "private",
sender_email: iago.email,
sender_full_name: iago.full_name,
sender_id: iago.user_id,
};
echo.insert_local_message(message_request, local_id_float, (message_data) => {
const messages = message_data.raw_messages;
messages.map((message) => echo.track_local_message(message));
return messages;
});
const increment_stub = make_stub();
override(pm_conversations.recent, "increment_local_message_count", increment_stub.f);
echo.reify_message_id(local_id_float.toString(), 120);
assert.equal(increment_stub.num_calls, 1);
const {user_ids} = increment_stub.get_args("user_ids");
assert.deepEqual(user_ids, [cordelia.user_id]);
});

View File

@@ -0,0 +1,63 @@
"use strict";
const assert = require("node:assert/strict");
/*
This module is kind of strange, because we don't
actually test the electron_bridge code, since that
code is basically a bunch of type declarations.
Instead, we test how other modules interact with
the types.
*/
const {mock_esm, set_global, with_overrides, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
mock_esm("../src/channel", {post: noop});
const electron_bridge = mock_esm("../src/electron_bridge");
set_global("document", {
hasFocus() {
return true;
},
});
const activity = zrequire("activity");
run_test("electron_bridge", () => {
function with_bridge_idle(bridge_idle, f) {
with_overrides(({override}) => {
override(electron_bridge, "electron_bridge", {
get_idle_on_system: () => bridge_idle,
});
return f();
});
}
with_bridge_idle(true, () => {
activity.mark_client_idle();
assert.equal(activity.compute_active_status(), "idle");
activity.mark_client_active();
assert.equal(activity.compute_active_status(), "idle");
});
with_overrides(({override}) => {
override(electron_bridge, "electron_bridge", undefined);
activity.mark_client_idle();
assert.equal(activity.compute_active_status(), "idle");
activity.mark_client_active();
assert.equal(activity.compute_active_status(), "active");
});
with_bridge_idle(false, () => {
activity.mark_client_idle();
assert.equal(activity.compute_active_status(), "active");
activity.mark_client_active();
assert.equal(activity.compute_active_status(), "active");
});
assert.ok(!activity.received_new_messages);
activity.set_received_new_messages(true);
assert.ok(activity.received_new_messages);
});

133
web/tests/emoji.test.cjs Normal file
View File

@@ -0,0 +1,133 @@
"use strict";
const assert = require("node:assert/strict");
const events = require("./lib/events.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const emoji_codes = zrequire("../../static/generated/emoji/emoji_codes.json");
const emoji = zrequire("emoji");
const realm_emoji = events.test_realm_emojis;
run_test("sanity check", () => {
// Invalid emoji data
emoji_codes.names = [...emoji_codes.names, "invalid_emoji"];
blueslip.expect("error", "No codepoint for emoji name invalid_emoji");
emoji.initialize({realm_emoji, emoji_codes});
// Valid data
emoji_codes.names = emoji_codes.names.filter((name) => name !== "invalid_emoji");
emoji.initialize({realm_emoji, emoji_codes});
assert.equal(emoji.get_server_realm_emoji_data(), realm_emoji);
});
run_test("get_canonical_name", () => {
let canonical_name = emoji.get_canonical_name("green_tick");
assert.equal(canonical_name, "green_tick");
canonical_name = emoji.get_canonical_name("thumbs_up");
assert.equal(canonical_name, "+1");
canonical_name = emoji.get_canonical_name("+1");
assert.equal(canonical_name, "+1");
canonical_name = emoji.get_canonical_name("airplane");
assert.equal(canonical_name, "airplane");
canonical_name = emoji.get_canonical_name("non_existent");
assert.equal(canonical_name, undefined);
});
run_test("get_emoji_* API", () => {
assert.equal(emoji.get_emoji_name("1f384"), "holiday_tree");
assert.equal(emoji.get_emoji_name("1f951"), "avocado");
assert.equal(emoji.get_emoji_name("bogus"), undefined);
assert.equal(emoji.get_emoji_codepoint("avocado"), "1f951");
assert.equal(emoji.get_emoji_codepoint("holiday_tree"), "1f384");
assert.equal(emoji.get_emoji_codepoint("bogus"), undefined);
assert.equal(emoji.get_realm_emoji_url("spain"), "/some/path/to/spain.gif");
});
run_test("get_emoji_details_by_name", () => {
let emoji_name = "smile";
let result = emoji.get_emoji_details_by_name(emoji_name);
assert.deepEqual(result, {
emoji_name: "smile",
emoji_code: "1f604",
reaction_type: "unicode_emoji",
});
// Test adding an unicode_emoji.
emoji_name = "smile";
result = emoji.get_emoji_details_by_name(emoji_name);
assert.deepEqual(result, {
emoji_name: "smile",
reaction_type: "unicode_emoji",
emoji_code: "1f604",
});
// Test adding zulip emoji.
emoji_name = "zulip";
result = emoji.get_emoji_details_by_name(emoji_name);
assert.deepEqual(result, {
emoji_name: "zulip",
reaction_type: "zulip_extra_emoji",
emoji_code: "zulip",
url: "/static/generated/emoji/images/emoji/unicode/zulip.png",
still_url: null,
});
// Test adding realm emoji.
emoji_name = "spain";
emoji_name = emoji.get_emoji_details_by_name(emoji_name);
assert.deepEqual(emoji_name, {
emoji_name: "spain",
reaction_type: "realm_emoji",
emoji_code: "101",
url: "/some/path/to/spain.gif",
still_url: "/some/path/to/spain.png",
});
emoji_name = "green_tick";
emoji_name = emoji.get_emoji_details_by_name(emoji_name);
assert.deepEqual(emoji_name, {
emoji_name: "green_tick",
reaction_type: "realm_emoji",
emoji_code: "102",
url: "/some/path/to/emoji",
still_url: null,
});
// Test sending without emoji name.
assert.throws(
() => {
emoji.get_emoji_details_by_name();
},
{
name: "Error",
message: "Emoji name must be passed.",
},
);
// Test sending an unknown emoji.
emoji_name = "unknown-emoji";
assert.throws(
() => {
emoji.get_emoji_details_by_name(emoji_name);
},
{
name: "Error",
message: "Bad emoji name: unknown-emoji",
},
);
});

View File

@@ -0,0 +1,147 @@
"use strict";
const assert = require("node:assert/strict");
const _ = require("lodash");
const {zrequire, set_global, mock_esm} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const emoji = zrequire("emoji");
const emoji_frequency = zrequire("emoji_frequency");
const map = new Map();
const emoji_frequency_data = mock_esm("../src/emoji_frequency_data", {
reaction_data: {
set: (emoji, emoji_usage) => map.set(emoji, emoji_usage),
},
preferred_emoji_list() {
const emojis = map
.values()
.toArray()
.toSorted((a, b) => b.score - a.score);
const count = emojis.length;
const emojis_per_row = 6;
return emojis.slice(0, count - (count % emojis_per_row));
},
});
const emoji_picker = zrequire("emoji_picker");
const typeahead = zrequire("typeahead");
const emoji_codes = zrequire("../../static/generated/emoji/emoji_codes.json");
set_global("document", "document-stub");
run_test("initialize", () => {
emoji.initialize({
realm_emoji: {},
emoji_codes,
});
typeahead.set_frequently_used_emojis(typeahead.get_popular_emojis());
emoji_picker.initialize();
const complete_emoji_catalog = _.sortBy(emoji_picker.complete_emoji_catalog, "name");
assert.equal(complete_emoji_catalog.length, 11);
assert.equal(emoji.emojis_by_name.size, 1884);
let total_emoji_in_categories = 0;
function assert_emoji_category(ele, icon, num) {
assert.equal(ele.icon, icon);
assert.equal(ele.emojis.length, num);
function check_emojis(val) {
for (const this_emoji of ele.emojis) {
assert.equal(this_emoji.is_realm_emoji, val);
}
}
if (ele.name === "Custom") {
check_emojis(true);
} else {
check_emojis(false);
total_emoji_in_categories += ele.emojis.length;
}
}
const popular_emoji_count = 6;
const zulip_emoji_count = 1;
assert_emoji_category(complete_emoji_catalog.pop(), "fa-car", 195);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-hashtag", 224);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-smile-o", 169);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-thumbs-o-up", 386);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-lightbulb-o", 264);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-star-o", popular_emoji_count);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-cutlery", 131);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-flag", 270);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-cog", 1);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-leaf", 159);
assert_emoji_category(complete_emoji_catalog.pop(), "fa-soccer-ball-o", 85);
// The popular emoji appear twice in the picker, and the zulip emoji is special
assert.equal(
emoji.emojis_by_name.size,
total_emoji_in_categories - popular_emoji_count + zulip_emoji_count,
);
const make_emoji = (emoji_code, score) => ({
emoji_code,
emoji_type: "unicode_emoji",
score,
});
// Note: The 18 here no longer matches POPULAR_EMOJIS_BONUS_WEIGHT
const popular_emojis = typeahead.popular_emojis.map((emoji_code) => make_emoji(emoji_code, 18));
const non_popular_emoji_codes = [
"1f3df", // stadium
"1f4b0", // money bag
"1f3e3", // japanese post office
"1f43c", // panda face
"1f648", // see no evil
"1f600", // grinning face
"1f680", // rocket
];
const non_popular_emojis_usage = Array.from(
non_popular_emoji_codes,
(non_popular_emoji_code, i) => make_emoji(non_popular_emoji_code, i + 10),
);
for (const emoji of [...popular_emojis, ...non_popular_emojis_usage]) {
emoji_frequency_data.reaction_data.set(emoji.emoji_code, emoji);
}
emoji_frequency.update_frequently_used_emojis_list();
non_popular_emoji_codes.reverse();
assert.equal(typeahead.frequently_used_emojis.length, 12);
assert.deepEqual(
typeahead.frequently_used_emojis.map((emoji) => emoji.emoji_code),
[...typeahead.popular_emojis, ...non_popular_emoji_codes.slice(0, 6)],
);
});
run_test("is_emoji_present_in_text", () => {
const thermometer_emoji = {
name: "thermometer",
emoji_code: "1f321",
reaction_type: "unicode_emoji",
};
const headphones_emoji = {
name: "headphones",
emoji_code: "1f3a7",
reaction_type: "unicode_emoji",
};
assert.equal(emoji_picker.is_emoji_present_in_text("🌡", thermometer_emoji), true);
assert.equal(
emoji_picker.is_emoji_present_in_text("no emojis at all", thermometer_emoji),
false,
);
assert.equal(emoji_picker.is_emoji_present_in_text("😎", thermometer_emoji), false);
assert.equal(emoji_picker.is_emoji_present_in_text("😎🌡🎧", thermometer_emoji), true);
assert.equal(emoji_picker.is_emoji_present_in_text("😎🎧", thermometer_emoji), false);
assert.equal(emoji_picker.is_emoji_present_in_text("😎🌡🎧", headphones_emoji), true);
assert.equal(
emoji_picker.is_emoji_present_in_text("emojis with text 😎🌡🎧", thermometer_emoji),
true,
);
assert.equal(
emoji_picker.is_emoji_present_in_text("emojis with text no space😎🌡🎧", headphones_emoji),
true,
);
});

View File

@@ -0,0 +1,77 @@
"use strict";
// This is a general tour of how to write node tests that
// may also give you some quick insight on how the Zulip
// browser app is constructed.
// The statements below are pretty typical for most node
// tests. The reason we need these helpers will hopefully
// become clear as you keep reading.
const assert = require("node:assert/strict");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
// We will use our special zrequire helper to import the
// Zulip code. We use zrequire instead of require,
// because it has some magic to clear state when we move
// on to the next test.
const people = zrequire("people");
const stream_data = zrequire("stream_data");
const util = zrequire("util");
// Let's start with testing a function from util.ts.
//
// The most basic unit tests load up code, call functions,
// and assert truths:
assert.ok(!util.find_stream_wildcard_mentions("boring text"));
assert.ok(util.find_stream_wildcard_mentions("mention @**everyone**"));
// Let's test with people.js next. We'll show this technique:
// * get a false value
// * change the data
// * get a true value
const isaac = make_user({
email: "isaac@example.com",
user_id: 30,
full_name: "Isaac Newton",
});
// The `people` object is a very fundamental object in the
// Zulip app. You can learn a lot more about it by reading
// the tests in people.test.cjs in the same directory as this file.
// Let's exercise the code and use assert to verify it works!
assert.ok(!people.is_known_user_id(isaac.user_id));
people.add_active_user(isaac);
assert.ok(people.is_known_user_id(isaac.user_id));
// Let's look at stream_data next, and we will start by putting
// some data at module scope. (You could also declare this inside
// the test, if you prefer.)
// We use make_stream to create a complete stream object with select
// fields explicitly specified, and all other fields populated with
// reasonable defaults.
const denmark_stream = make_stream({
color: "a1a1a1",
name: "Denmark",
subscribed: false,
});
// We introduce the run_test helper, which mostly just causes
// a line of output to go to the console. It does a little more than
// that, which we will see later.
run_test("verify stream_data persists stream color", () => {
stream_data.clear_subscriptions();
assert.equal(stream_data.get_sub_by_name("Denmark"), undefined);
stream_data.add_sub_for_tests(denmark_stream);
const sub = stream_data.get_sub_by_name("Denmark");
assert.equal(sub.color, "a1a1a1");
});
// See example2.test.cjs in this directory.

111
web/tests/example2.test.cjs Normal file
View File

@@ -0,0 +1,111 @@
"use strict";
const assert = require("node:assert/strict");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
// Hopefully the basic patterns for testing data-oriented modules
// are starting to become apparent. To reinforce that, we will present
// few more examples that also expose you to some of our core
// data objects. Also, we start testing some objects that have
// deeper dependencies.
const message_helper = zrequire("message_helper");
const message_store = zrequire("message_store");
const people = zrequire("people");
const stream_data = zrequire("stream_data");
const stream_topic_history = zrequire("stream_topic_history");
const unread = zrequire("unread");
const {initialize_user_settings} = zrequire("user_settings");
// It's typical to set up a little bit of data at the top of a
// test module, but you can also do this within tests. Here we
// will set up things at the top.
initialize_user_settings({user_settings: {}});
const isaac = make_user({
email: "isaac@example.com",
user_id: 30,
full_name: "Isaac Newton",
});
const denmark_stream = make_stream({
color: "blue",
name: "Denmark",
stream_id: 101,
subscribed: false,
});
const messages = {
isaac_to_denmark_stream: {
id: 400,
sender_id: isaac.user_id,
stream_id: denmark_stream.stream_id,
type: "stream",
flags: ["has_alert_word"],
subject: "copenhagen",
reactions: [],
submessages: [],
// note we don't have every field that a "real" message
// would have, and that can be fine
},
};
// We aren't going to modify isaac in our tests, so we will
// create him at the top.
people.add_active_user(isaac);
// We are going to test a core module called messages_store.js next.
// This is an example of a deep unit test, where our dependencies
// are easy to test.
run_test("message_store", () => {
message_store.clear_for_testing();
stream_data.clear_subscriptions();
stream_data.add_sub_for_tests(denmark_stream);
const in_message = {...messages.isaac_to_denmark_stream};
assert.equal(in_message.alerted, undefined);
// Let's add a message into our message_store via
// message_helper.process_new_message.
assert.equal(message_store.get(in_message.id), undefined);
message_helper.process_new_message({
type: "server_message",
raw_message: in_message,
});
const message = message_store.get(in_message.id);
assert.equal(message.alerted, true);
// There are more side effects.
const topic_names = stream_topic_history.get_recent_topic_names(denmark_stream.stream_id);
assert.deepEqual(topic_names, ["copenhagen"]);
});
// Tracking unread messages is a very fundamental part of the Zulip
// app, and we use the unread object to track unread messages.
run_test("unread", () => {
unread.declare_bankruptcy();
stream_data.clear_subscriptions();
stream_data.add_sub_for_tests(denmark_stream);
const stream_id = denmark_stream.stream_id;
const topic_name = "copenhagen";
assert.equal(unread.num_unread_for_topic(stream_id, topic_name), 0);
let in_message = {...messages.isaac_to_denmark_stream};
in_message = message_helper.process_new_message({
type: "server_message",
raw_message: in_message,
});
unread.process_loaded_messages([in_message.message]);
assert.equal(unread.num_unread_for_topic(stream_id, topic_name), 1);
});

View File

@@ -0,0 +1,93 @@
"use strict";
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_message_list} = require("./lib/message_list.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
// In the Zulip app you can narrow your message stream by topic, by
// sender, by direct message recipient, by search keywords, etc.
// We will discuss narrows more broadly, but first let's test out a
// core piece of code that makes things work.
const {Filter} = zrequire("../src/filter");
const {set_realm} = zrequire("state_data");
const stream_data = zrequire("stream_data");
set_realm(make_realm());
const denmark_stream = make_stream({
color: "blue",
name: "Denmark",
stream_id: 101,
subscribed: false,
});
run_test("filter", () => {
stream_data.clear_subscriptions();
stream_data.add_sub_for_tests(denmark_stream);
const filter_terms = [
{operator: "stream", operand: denmark_stream.stream_id.toString()},
{operator: "topic", operand: "copenhagen"},
];
const filter = new Filter(filter_terms);
const predicate = filter.predicate();
// We don't need full-fledged messages to test the gist of
// our filter. If there are details that are distracting from
// your test, you should not feel guilty about removing them.
assert.equal(predicate({type: "personal"}), false);
assert.equal(
predicate({
type: "stream",
stream_id: denmark_stream.stream_id,
topic: "does not match filter",
}),
false,
);
assert.equal(
predicate({
type: "stream",
stream_id: denmark_stream.stream_id,
topic: "copenhagen",
}),
true,
);
});
// We have a "narrow" abstraction that sits roughly on top of the
// "filter" abstraction. If you are in a narrow, we track the
// state with the narrow_state module.
const narrow_state = zrequire("narrow_state");
const message_lists = zrequire("message_lists");
run_test("narrow_state", () => {
stream_data.clear_subscriptions();
stream_data.add_sub_for_tests(denmark_stream);
message_lists.set_current(undefined);
// As we often do, first make assertions about the starting
// state:
assert.equal(narrow_state.stream_name(), undefined);
// Now set up a Filter object.
const filter_terms = [
{operator: "stream", operand: denmark_stream.stream_id.toString()},
{operator: "topic", operand: "copenhagen"},
];
// And here is where we actually change state.
message_lists.set_current(make_message_list(filter_terms));
assert.equal(narrow_state.stream_name(), "Denmark");
assert.equal(narrow_state.topic(), "copenhagen");
});

177
web/tests/example4.test.cjs Normal file
View File

@@ -0,0 +1,177 @@
"use strict";
const assert = require("node:assert/strict");
const {make_bot} = require("./lib/example_user.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
/*
Let's step back and review what we've done so far.
We've used fairly straightforward testing techniques
to explore the following modules:
filter
message_store
narrow_state
people
stream_data
util
We haven't gone deep on any of these objects, but if
you are interested, all of these objects have test
suites that have 100% line coverage on the modules
that implement those objects. For example, you can look
at people.test.cjs in this directory for more tests on the
people object.
We can quickly review some testing concepts:
zrequire - bring in real code
mock_esm - mock es6 modules
assert.equal - verify results
------
Let's talk about our next steps.
An app is pretty useless without an actual data source.
One of the primary ways that a Zulip client gets data
is through events. (We also get data at page load, and
we can also ask the server for data, but that's not in
the scope of this conversation yet.)
Chat systems are dynamic. If an admin adds a user, or
if a user sends a messages, the server immediately sends
events to all clients so that they can reflect appropriate
changes in their UI. We're not going to discuss the entire
"full stack" mechanism here. Instead, we'll focus on
the client code, starting at the boundary where we
process events.
Let's just get started...
*/
// We are going to use mock versions of some of our libraries.
const activity_ui = mock_esm("../src/activity_ui");
const message_live_update = mock_esm("../src/message_live_update");
const pm_list = mock_esm("../src/pm_list");
const settings_bots = mock_esm("../src/settings_bots");
const settings_users = mock_esm("../src/settings_users");
const user_profile = mock_esm("../src/user_profile");
mock_esm("../src/compose_recipient", {
update_user_name_in_compose() {},
});
mock_esm("../src/message_view_header", {
maybe_update_navbar_title_for_user() {},
});
// Use real versions of these modules.
const bot_data = zrequire("bot_data");
const people = zrequire("people");
const server_events_dispatch = zrequire("server_events_dispatch");
const {set_current_user} = zrequire("state_data");
set_current_user({});
const bob = make_bot({
email: "bob@example.com",
user_id: 33,
full_name: "Bob Roberts",
});
run_test("add users with event", ({override}) => {
people.init();
const event = {
type: "realm_user",
op: "add",
person: bob,
};
assert.ok(!people.is_known_user_id(bob.user_id));
// We need to override a stub here before dispatching the event.
// Keep reading to see how overriding works!
override(settings_bots, "redraw_all_bots_list", noop);
override(activity_ui, "check_should_redraw_new_user", noop);
override(pm_list, "update_private_messages", noop);
// Let's simulate dispatching our event!
server_events_dispatch.dispatch_normal_event(event);
// And it works!
assert.ok(people.is_known_user_id(bob.user_id));
});
/*
It's actually a little surprising that adding a user does
not have side effects beyond the people object, the bots list,
and the direct messages list. I guess we don't immediately
update the buddy list, but that's because the buddy list gets
updated on the next server fetch.
Let's try an update next. To make this work, we will want
to override some more of our stubs.
This is where we see a little extra benefit from the
run_test wrapper. It passes us in an object that we
can use to override data, and that works within the
scope of the function.
*/
run_test("update user with event", ({override}) => {
people.init();
people.add_active_user(bob);
bot_data.add({
default_all_public_streams: true,
default_events_register_stream: "register stream test",
default_sending_stream: "sending stream test",
user_id: bob.user_id,
services: [],
});
set_current_user({user_id: bob.user_id});
const $select = $.create("#user-self-role-select");
const $option = $.create('option[value="100"]');
$select.set_find_results('option[value="100"]', $option);
const new_bob = {
user_id: bob.user_id,
full_name: "The Artist Formerly Known as Bob",
};
const event = {
type: "realm_user",
op: "update",
person: new_bob,
};
// We have to stub a few things. We don't want to test
// the details of these functions, but we do want to
// verify that they run. Fortunately, the run_test()
// wrapper will tell us if we override a method that
// doesn't get called!
override(activity_ui, "redraw", noop);
override(message_live_update, "update_user_full_name", noop);
override(pm_list, "update_private_messages", noop);
override(settings_users, "update_user_data", noop);
override(settings_bots, "update_bot_data", noop);
override(user_profile, "update_profile_modal_ui", noop);
// Dispatch the realm_user/update event, which will update
// data structures and have other side effects that are
// stubbed out above.
server_events_dispatch.dispatch_normal_event(event);
const user = people.get_by_user_id(bob.user_id);
// Verify that the code actually did its main job:
assert.equal(user.full_name, "The Artist Formerly Known as Bob");
});

197
web/tests/example5.test.cjs Normal file
View File

@@ -0,0 +1,197 @@
"use strict";
const assert = require("node:assert/strict");
const {make_user} = require("./lib/example_user.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
/*
Our test from an earlier example verifies that the update events
leads to a name change inside the people object, but it obviously
kind of glosses over the other interactions.
We can go a step further and verify the sequence of operations that
happen during an event. This concept is called "stubbing", and you
can find libraries to help do stubbing. Here we will just build our
own lightweight stubbing system, which is almost trivially easy to
do in a language like JavaScript.
*/
// First we tell the compiler to skip certain modules and just
// replace them with {}.
const direct_message_group_data = mock_esm("../src/direct_message_group_data");
const message_lists = mock_esm("../src/message_lists");
const message_notifications = mock_esm("../src/message_notifications");
const pm_list = mock_esm("../src/pm_list");
const stream_list = mock_esm("../src/stream_list");
const unread_ui = mock_esm("../src/unread_ui");
const activity = mock_esm("../src/activity");
mock_esm("../src/electron_bridge", {
electron_bridge: {},
});
let added_message = false;
message_lists.current = {
data: {
filter: {
can_apply_locally() {
return true;
},
},
},
add_messages() {
added_message = true;
},
};
message_lists.all_rendered_message_lists = () => [message_lists.current];
message_lists.non_rendered_data = () => [];
// And we will also test some real code, of course.
const message_events = zrequire("message_events");
const message_store = zrequire("message_store");
const people = zrequire("people");
const {initialize_user_settings} = zrequire("user_settings");
initialize_user_settings({user_settings: {}});
const isaac = make_user({
email: "isaac@example.com",
user_id: 30,
full_name: "Isaac Newton",
});
people.add_active_user(isaac);
/*
Next we create a test_helper that will allow us to redirect methods to an
events array, and we can then later verify that the sequence of side effect
is as predicted.
(Note that for now we don't simulate return values nor do we inspect the
arguments to these functions. We could easily extend our helper to do more.)
The forthcoming example is a pretty extreme example, where we are calling a
pretty high level method that dispatches a lot of its work out to other
objects.
*/
function test_helper({override}) {
const events = [];
return {
redirect(module, func_name) {
override(module, func_name, () => {
events.push([module, func_name]);
});
},
events,
};
}
run_test("insert_server_message", ({override}) => {
message_store.clear_for_testing();
override(pm_list, "update_private_messages", noop);
const helper = test_helper({override});
const new_message = {
sender_id: isaac.user_id,
id: 1001,
content: "example content",
topic: "Foo",
type: "stream",
reactions: [],
avatar_url: `/avatar/${isaac.user_id}`,
display_recipient: "Bar",
submessages: [],
};
assert.equal(message_store.get(new_message.id), undefined);
helper.redirect(direct_message_group_data, "process_loaded_messages");
helper.redirect(message_notifications, "received_messages");
helper.redirect(stream_list, "update_streams_sidebar_for_messages");
helper.redirect(unread_ui, "update_unread_counts");
helper.redirect(activity, "set_received_new_messages");
message_events.insert_new_messages({
type: "server_message",
raw_messages: [new_message],
});
// Even though we have stubbed a *lot* of code, our
// tests can still verify the main "narrative" of how
// the code invokes various objects when a new message
// comes in:
assert.deepEqual(helper.events, [
[direct_message_group_data, "process_loaded_messages"],
[unread_ui, "update_unread_counts"],
[activity, "set_received_new_messages"],
[message_notifications, "received_messages"],
[stream_list, "update_streams_sidebar_for_messages"],
]);
assert.ok(added_message);
// Despite all of our stubbing/mocking, the call to
// insert_new_messages will have created a very important
// side effect that we can verify:
const inserted_message = message_store.get(new_message.id);
assert.equal(inserted_message.id, new_message.id);
assert.equal(inserted_message.content, "example content");
});
run_test("insert_local_message", ({override}) => {
message_store.clear_for_testing();
override(pm_list, "update_private_messages", noop);
const helper = test_helper({override});
const new_message = {
sender_id: isaac.user_id,
id: 1001,
content: "example content",
topic: "Foo",
type: "stream",
reactions: [],
avatar_url: `/avatar/${isaac.user_id}`,
display_recipient: "Bar",
draft_id: 1,
local_id: "1001.1",
};
assert.equal(message_store.get(new_message.id), undefined);
helper.redirect(direct_message_group_data, "process_loaded_messages");
helper.redirect(message_notifications, "received_messages");
helper.redirect(stream_list, "update_streams_sidebar_for_messages");
helper.redirect(activity, "set_received_new_messages");
message_events.insert_new_messages({
type: "local_message",
raw_messages: [new_message],
});
// Even though we have stubbed a *lot* of code, our
// tests can still verify the main "narrative" of how
// the code invokes various objects when a new message
// comes in:
assert.deepEqual(helper.events, [
[direct_message_group_data, "process_loaded_messages"],
[activity, "set_received_new_messages"],
[message_notifications, "received_messages"],
[stream_list, "update_streams_sidebar_for_messages"],
]);
assert.ok(added_message);
// Despite all of our stubbing/mocking, the call to
// insert_new_messages will have created a very important
// side effect that we can verify:
const inserted_message = message_store.get(new_message.id);
assert.equal(inserted_message.id, new_message.id);
assert.equal(inserted_message.content, "example content");
});

View File

@@ -0,0 +1,69 @@
"use strict";
const assert = require("node:assert/strict");
const {make_stub} = require("./lib/stub.cjs");
const {run_test, noop} = require("./lib/test.cjs");
/*
The previous example was a bit extreme. Generally we just
use the make_stub helper that comes with zjsunit.
We will step away from the actual Zulip codebase for a
second and just explore a contrived example.
*/
run_test("explore make_stub", ({override}) => {
// Let's say you have to test the following code.
const app = {
/* istanbul ignore next */
notify_server_of_deposit(deposit_amount) {
// simulate difficulty
throw new Error(`We cannot report this value without wifi: ${deposit_amount}`);
},
/* istanbul ignore next */
pop_up_fancy_confirmation_screen(deposit_amount, label) {
// simulate difficulty
throw new Error(`We cannot make a ${label} dialog for amount ${deposit_amount}`);
},
};
let balance = 40;
function deposit_paycheck(paycheck_amount) {
balance += paycheck_amount;
app.notify_server_of_deposit(paycheck_amount);
app.pop_up_fancy_confirmation_screen(paycheck_amount, "paycheck");
}
// Our deposit_paycheck should be easy to unit test for its
// core functionality (updating your balance), but the side
// effects get in the way. We have to override them to do
// the simple test here.
override(app, "notify_server_of_deposit", noop);
override(app, "pop_up_fancy_confirmation_screen", noop);
deposit_paycheck(10);
assert.equal(balance, 50);
// But we can do a little better here. Even though
// the two side-effect functions are awkward here, we can
// at least make sure they are invoked correctly. Let's
// use stubs.
const notify_stub = make_stub();
const pop_up_stub = make_stub();
// This time we'll just use our override helper to connect the
// stubs.
override(app, "notify_server_of_deposit", notify_stub.f);
override(app, "pop_up_fancy_confirmation_screen", pop_up_stub.f);
deposit_paycheck(25);
assert.equal(balance, 75);
assert.deepEqual(notify_stub.get_args("amount"), {amount: 25});
assert.deepEqual(pop_up_stub.get_args("amount", "label"), {amount: 25, label: "paycheck"});
});

160
web/tests/example7.test.cjs Normal file
View File

@@ -0,0 +1,160 @@
"use strict";
const assert = require("node:assert/strict");
const {make_stream} = require("./lib/example_stream.cjs");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
/*
Let's continue to explore how we can test complex
interactions in the real code.
When a new message comes in, we update the three major
panes of the app:
* left sidebar - stream list
* middle pane - message view
* right sidebar - buddy list (aka "activity" list)
These are reflected by the following calls:
stream_list.update_streams_sidebar
message_util.add_new_messages
activity.process_loaded_messages
For now, though, let's focus on another side effect
of processing incoming messages:
unread_ops.process_visible
When new messages come in, they are often immediately
visible to users, so the app will communicate this
back to the server by calling unread_ops.process_visible.
In order to unit test this, we don't want to require
an actual server to be running. Instead, this example
will stub many of the "boundaries" to focus on the
core behavior.
The two key pieces here are as follows:
* Use mock_esm to avoid compiling the "real"
modules that are immaterial to our current
testing concerns.
* Use override(...) to simulate how we want
methods to behave. (Often we want them to
do nothing at all or return a simple
value.)
*/
set_global("document", {hasFocus: () => true});
const channel = mock_esm("../src/channel");
const desktop_notifications = mock_esm("../src/desktop_notifications");
const message_lists = mock_esm("../src/message_lists");
const message_viewport = mock_esm("../src/message_viewport");
const unread_ui = mock_esm("../src/unread_ui");
message_lists.current = {view: {}};
message_lists.all_rendered_message_lists = () => [message_lists.current];
const message_store = zrequire("message_store");
const stream_data = zrequire("stream_data");
const unread = zrequire("unread");
const unread_ops = zrequire("unread_ops");
const denmark_stream = make_stream({
color: "blue",
name: "Denmark",
stream_id: 101,
subscribed: false,
});
run_test("unread_ops", ({override}) => {
stream_data.clear_subscriptions();
stream_data.add_sub_for_tests(denmark_stream);
message_store.clear_for_testing();
unread.declare_bankruptcy();
const message_id = 50;
const test_messages = [
{
id: message_id,
type: "stream",
stream_id: denmark_stream.stream_id,
topic: "copenhagen",
unread: true,
},
];
// Make our test message appear to be unread, so that
// we then need to subsequently process them as read.
message_store.update_message_cache({
type: "server_message",
message: test_messages[0],
});
unread.process_loaded_messages(test_messages);
// Make our message_viewport appear visible.
$("#message_feed_container").show();
// Make our "test" message appear visible.
override(message_viewport, "bottom_rendered_message_visible", () => true);
// Set message_lists.current containing messages that can be marked read
override(message_lists.current, "all_messages", () => test_messages);
// Ignore these interactions for now:
override(message_lists.current.view, "show_message_as_read", noop);
override(desktop_notifications, "close_notification", noop);
override(unread_ui, "update_unread_counts", noop);
override(unread_ui, "notify_messages_remain_unread", noop);
// Set up a way to capture the options passed in to channel.post.
let channel_post_opts;
override(channel, "post", (opts) => {
channel_post_opts = opts;
});
let can_mark_messages_read;
// Set up an override to point to the above var, so we can
// toggle it easily from within the test (and avoid complicated
// data setup).
override(message_lists.current, "can_mark_messages_read", () => can_mark_messages_read);
override(message_lists.current, "has_unread_messages", () => true);
override(message_lists.current.view, "is_fetched_end_rendered", () => true);
// First, test for a message list that cannot read messages.
can_mark_messages_read = false;
unread_ops.process_visible();
assert.deepEqual(channel_post_opts, undefined);
// Now flip the boolean, and get to the main thing we are testing.
can_mark_messages_read = true;
// Don't mark messages as read until all messages in the narrow are fetched and rendered.
override(message_lists.current.view, "is_fetched_end_rendered", () => false);
unread_ops.process_visible();
assert.deepEqual(channel_post_opts, undefined);
override(message_lists.current.view, "is_fetched_end_rendered", () => true);
unread_ops.process_visible();
// The most important side effect of the above call is that
// we post info to the server. We can verify that the correct
// url and parameters are specified:
assert.deepEqual(channel_post_opts, {
url: "/json/messages/flags",
data: {messages: "[50]", op: "add", flag: "read"},
success: channel_post_opts.success,
});
// Simulate a successful post (which also clears the queue
// in our message_flag code).
channel_post_opts.success({messages: [message_id]});
});

151
web/tests/example8.test.cjs Normal file
View File

@@ -0,0 +1,151 @@
"use strict";
const assert = require("node:assert/strict");
const {make_user} = require("./lib/example_user.cjs");
const {make_message_list} = require("./lib/message_list.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
mock_esm("../src/settings_data", {
user_can_access_all_other_users: () => true,
});
/*
Until now, we had seen various testing techniques, learned
how to use helper functions like `mock_esm`, `override` of
`run_test` etc., but we didn't see how to deal with
render calls to Handlebars templates. We'll learn that
in this test.
The below code tests the rendering of typing notifications which
is handled by the function `typing_events.render_notifications_for_narrow`.
The function relies on the `typing_notifications.hbs` template for
rendering html.
It is worthwhile to read those (they're short and simple) before proceeding
as that would help better understand the below test.
*/
const message_lists = zrequire("message_lists");
const people = zrequire("people");
const {set_current_user} = zrequire("state_data");
const typing_data = zrequire("typing_data");
const typing_events = zrequire("typing_events");
const current_user = {};
set_current_user(current_user);
// Let us add a few users to use as typists.
const anna = make_user({
email: "anna@example.com",
full_name: "Anna Karenina",
user_id: 8,
});
const vronsky = make_user({
email: "vronsky@example.com",
full_name: "Alexei Vronsky",
user_id: 9,
});
const levin = make_user({
email: "levin@example.com",
full_name: "Konstantin Levin",
user_id: 10,
});
const kitty = make_user({
email: "kitty@example.com",
full_name: "Kitty S",
user_id: 11,
});
people.add_active_user(anna);
people.add_active_user(vronsky);
people.add_active_user(levin);
people.add_active_user(kitty);
/*
Notice the `mock_template` in the object passed to `run_test` wrapper below.
It is pretty similar to `override` we've seen in previous examples but
mocks a template instead of a js function.
Just like `override`, `mock_template` lets us run a function taking in
the arguments passed to the template. Additionally, we can also have
the rendered html passed as an argument.
It's usage below will make it more clear to you.
*/
run_test("typing_events.render_notifications_for_narrow", ({override, mock_template}) => {
// All typists are rendered in `#typing_notifications`.
const $typing_notifications = $("#typing_notifications");
// Narrow to a group direct message with four users.
override(current_user, "user_id", anna.user_id);
const group = [anna.user_id, vronsky.user_id, levin.user_id, kitty.user_id];
const conversation_key = typing_data.get_direct_message_conversation_key(group);
message_lists.set_current(make_message_list([{operator: "dm", operand: group}]));
// Based on typing_events.MAX_USERS_TO_DISPLAY_NAME (which is currently 3),
// we display either the list of all users typing (if they do not exceed
// MAX_USERS_TO_DISPLAY_NAME) or 'Several people are typing…'
// For now, set two of the users as being typists.
typing_data.add_typist(conversation_key, anna.user_id);
typing_data.add_typist(conversation_key, vronsky.user_id);
const two_typing_users_rendered_html = "Two typing users rendered html stub";
// As you can see below, the first argument of mock_template takes
// the relative path of the template we want to mock w.r.t web/templates/
//
// The second argument takes a boolean determining whether to render html.
// We mostly set this to `false` and recommend you avoid setting this to `true`
// unless necessary in situations where you want to test conditionals
// or something similar. The latter examples below would make that more clear.
//
// The third takes a function to run on calling this template. The function
// gets passed an object(`args` below) containing arguments passed to the template.
// Additionally, it can also have rendered html passed to it if second argument of
// mock_template was set to `true`. Any render calls to this template
// will run the function and return the function's return value.
//
// We often use the function in third argument, like below, to make sure
// the arguments passed to the template are what we expect.
mock_template("typing_notifications.hbs", false, (args) => {
assert.deepEqual(args.users, [anna, vronsky]);
assert.ok(!args.several_users); // Whether to show 'Several people are typing…'
return two_typing_users_rendered_html;
});
typing_events.render_notifications_for_narrow();
// Make sure #typing_notifications's html content is set to the rendered template
// which we mocked and gave a custom return value.
assert.equal($typing_notifications.html(), two_typing_users_rendered_html);
// Now we'll see how setting the second argument to `true`
// can be helpful in testing conditionals inside the template.
// Let's set the mock to just return the rendered html.
mock_template("typing_notifications.hbs", true, (_args, rendered_html) => rendered_html);
// Since we only have two(<MAX_USERS_TO_DISPLAY_NAME) typists, both of them
// should be rendered but not 'Several people are typing…'
typing_events.render_notifications_for_narrow();
assert.ok($typing_notifications.html().includes(`${anna.full_name} is typing…`));
assert.ok($typing_notifications.html().includes(`${vronsky.full_name} is typing…`));
assert.ok(!$typing_notifications.html().includes("Several people are typing…"));
// Change to having four typists and verify the rendered html has
// 'Several people are typing…' but not the list of users.
typing_data.add_typist(conversation_key, levin.user_id);
typing_data.add_typist(conversation_key, kitty.user_id);
typing_events.render_notifications_for_narrow();
assert.ok($typing_notifications.html().includes("Several people are typing…"));
assert.ok(!$typing_notifications.html().includes(`${anna.full_name} is typing…`));
assert.ok(!$typing_notifications.html().includes(`${vronsky.full_name} is typing…`));
assert.ok(!$typing_notifications.html().includes(`${levin.full_name} is typing…`));
assert.ok(!$typing_notifications.html().includes(`${kitty.full_name} is typing…`));
});

View File

@@ -0,0 +1,176 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const fenced_code = zrequire("fenced_code");
// Check the default behavior of fenced code blocks
// works properly before Markdown is initialized.
run_test("fenced_block_defaults", () => {
const input = "\n```\nfenced code\n```\n\nand then after\n";
const expected =
'\n\n<div class="codehilite"><pre><span></span><code>fenced code\n</code></pre></div>\n\n\nand then after\n\n';
const output = fenced_code.process_fenced_code(input);
assert.equal(output, expected);
});
run_test("get_unused_fence", () => {
assert.equal(fenced_code.get_unused_fence("```js\nsomething\n```"), "`".repeat(4));
assert.equal(fenced_code.get_unused_fence("````\nsomething\n````"), "`".repeat(5));
assert.equal(fenced_code.get_unused_fence("```\n````\n``````"), "`".repeat(7));
assert.equal(fenced_code.get_unused_fence("~~~\nsomething\n~~~"), "`".repeat(3));
assert.equal(
fenced_code.get_unused_fence("```code\nterminating fence is indented and longer\n ````"),
"`".repeat(5),
);
assert.equal(
fenced_code.get_unused_fence("```code\nterminating fence is extra indented\n ````"),
"`".repeat(4),
);
let large_testcase = "";
// ```
// ````
// `````
// ... up to N chars
// We insert a N + 1 character fence.
for (let i = 3; i <= 20; i += 1) {
large_testcase += "`".repeat(i) + "\n";
}
assert.equal(fenced_code.get_unused_fence(large_testcase), "`".repeat(21));
});
run_test("wrap_code", () => {
const header = '<div class="codehilite"><pre><span></span><code>';
const footer = "</code></pre></div>";
// Without language: no data-code-language attribute.
assert.equal(fenced_code.wrap_code("hello\n"), header + "hello\n" + footer);
// With empty string language: treated the same as no language.
assert.equal(fenced_code.wrap_code("hello\n", ""), header + "hello\n" + footer);
// HTML escaping in code content.
assert.equal(
fenced_code.wrap_code('<script>alert("xss")</script>'),
header + "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;\n" + footer,
);
// Trailing newlines are trimmed to just one.
assert.equal(fenced_code.wrap_code("code\n\n\n"), header + "code\n" + footer);
// Leading newlines are trimmed.
assert.equal(fenced_code.wrap_code("\n\ncode"), header + "code\n" + footer);
});
run_test("wrap_code_with_language", () => {
const footer = "</code></pre></div>";
function header_with_lang(lang) {
return `<div class="codehilite" data-code-language="${lang}"><pre><span></span><code>`;
}
// Before initialize(), the lang string itself is used.
assert.equal(
fenced_code.wrap_code("x = 1", "mylang"),
header_with_lang("mylang") + "x = 1\n" + footer,
);
// After initialize(), known languages use pretty_name.
fenced_code.initialize({
langs: {
python: {priority: 1, pretty_name: "Python"},
js: {priority: 2, pretty_name: "JavaScript"},
},
});
assert.equal(
fenced_code.wrap_code("x = 1", "python"),
header_with_lang("Python") + "x = 1\n" + footer,
);
assert.equal(
fenced_code.wrap_code("var x;", "js"),
header_with_lang("JavaScript") + "var x;\n" + footer,
);
// Unknown language still works after initialize.
assert.equal(
fenced_code.wrap_code("fn main() {}", "rust"),
header_with_lang("rust") + "fn main() {}\n" + footer,
);
// Language with HTML special chars is escaped.
assert.equal(
fenced_code.wrap_code("code", 'a<b>"c'),
header_with_lang("a&lt;b&gt;&quot;c") + "code\n" + footer,
);
});
run_test("process_fenced_code_quote", () => {
const input = "```quote\nhello world\nline two\n```";
const output = fenced_code.process_fenced_code(input);
assert.equal(output, "\n> hello world\n> line two\n\n");
});
run_test("process_fenced_code_math", () => {
// Valid LaTeX renders via KaTeX.
const input = "```math\nx^2\n```";
const output = fenced_code.process_fenced_code(input);
assert.ok(output.includes("katex"));
// Invalid LaTeX produces a tex-error span.
const bad_input = "```math\n\\invalid{}{}{}\n```";
const bad_output = fenced_code.process_fenced_code(bad_input);
assert.ok(bad_output.includes("tex-error"));
});
run_test("process_fenced_code_spoiler", () => {
const stashed = [];
fenced_code.set_stash_func((text) => {
stashed.push(text);
return "STASHED";
});
const input = "```spoiler Click to reveal\nhidden content\n```";
const output = fenced_code.process_fenced_code(input);
assert.ok(output.includes("Click to reveal"));
assert.ok(output.includes("hidden content"));
assert.ok(stashed.some((s) => s.includes("spoiler-block")));
// Restore the default no-op stash func.
fenced_code.set_stash_func((text) => text);
});
run_test("process_fenced_code_tilde_fence", () => {
const input = "~~~\ntilde fenced\n~~~";
const output = fenced_code.process_fenced_code(input);
assert.equal(
output,
'\n<div class="codehilite"><pre><span></span><code>tilde fenced\n</code></pre></div>\n\n',
);
});
run_test("process_fenced_code_language_specifier", () => {
fenced_code.initialize({
langs: {
python: {priority: 1, pretty_name: "Python"},
},
});
const input = "```python\nprint('hello')\n```";
const output = fenced_code.process_fenced_code(input);
assert.ok(output.includes('data-code-language="Python"'));
assert.ok(output.includes("print(&#39;hello&#39;)"));
});
run_test("process_fenced_code_unclosed", () => {
// An unclosed code block should auto-close at EOF.
const input = "before\n```\nunclosed code";
const output = fenced_code.process_fenced_code(input);
assert.equal(
output,
'before\n\n<div class="codehilite"><pre><span></span><code>unclosed code\n</code></pre></div>\n\n',
);
});

View File

@@ -0,0 +1,179 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
mock_esm("../src/message_feed_loading", {
hide_loading_older() {},
show_loading_older() {},
hide_loading_newer() {},
show_loading_newer() {},
});
const {FetchStatus} = zrequire("fetch_status");
let fetch_status = new FetchStatus();
function reset() {
fetch_status = new FetchStatus();
}
function can_load_newer() {
assert.equal(fetch_status.can_load_newer_messages(), true);
}
function blocked_newer() {
assert.equal(fetch_status.can_load_newer_messages(), false);
}
function can_load_older() {
assert.equal(fetch_status.can_load_older_messages(), true);
}
function blocked_older() {
assert.equal(fetch_status.can_load_older_messages(), false);
}
function has_found_oldest() {
assert.equal(fetch_status.has_found_oldest(), true);
}
function has_not_found_oldest() {
assert.equal(fetch_status.has_found_oldest(), false);
}
function has_found_newest() {
assert.equal(fetch_status.has_found_newest(), true);
}
function has_not_found_newest() {
assert.equal(fetch_status.has_found_newest(), false);
}
function can_load_history() {
assert.equal(fetch_status.history_limited(), false);
}
function blocked_history() {
assert.equal(fetch_status.history_limited(), true);
}
run_test("basics", () => {
reset();
fetch_status.start_newer_batch({update_loading_indicator: false});
fetch_status.start_older_batch({update_loading_indicator: false});
blocked_newer();
blocked_older();
can_load_history();
has_not_found_oldest();
has_not_found_newest();
let data = {
update_loading_indicator: false,
found_oldest: true,
found_newest: true,
history_limited: true,
};
fetch_status.finish_newer_batch([], data);
fetch_status.finish_older_batch(data);
has_found_oldest();
has_found_newest();
blocked_newer();
blocked_older();
blocked_history();
reset();
fetch_status.start_newer_batch({update_loading_indicator: true});
fetch_status.start_older_batch({update_loading_indicator: true});
blocked_newer();
blocked_older();
can_load_history();
data = {
update_loading_indicator: false,
found_oldest: false,
found_newest: false,
history_limited: false,
};
fetch_status.finish_newer_batch([], data);
fetch_status.finish_older_batch(data);
can_load_older();
can_load_newer();
can_load_history();
reset();
can_load_older();
fetch_status.start_older_batch({update_loading_indicator: false});
blocked_older();
can_load_newer();
can_load_history();
fetch_status.finish_older_batch({
update_loading_indicator: true,
found_oldest: false,
history_limited: false,
});
can_load_older();
can_load_newer();
can_load_history();
fetch_status.start_older_batch({update_loading_indicator: true});
blocked_older();
can_load_newer();
can_load_history();
fetch_status.finish_older_batch({
update_loading_indicator: true,
found_oldest: true,
history_limited: true,
});
blocked_older();
can_load_newer();
blocked_history();
reset();
can_load_older();
can_load_newer();
fetch_status.start_newer_batch({update_loading_indicator: false});
can_load_older();
blocked_newer();
fetch_status.finish_newer_batch([], {
update_loading_indicator: true,
found_newest: false,
});
can_load_older();
can_load_newer();
fetch_status.start_newer_batch({update_loading_indicator: true});
can_load_older();
blocked_newer();
fetch_status.finish_newer_batch([], {
update_loading_indicator: true,
found_newest: true,
});
can_load_older();
blocked_newer();
});

3506
web/tests/filter.test.cjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const focus_outline_util = zrequire("focus_outline_util");
run_test("removes class on first navigation keypress", () => {
const $container = $("#test-container");
$container.addClass("no-visible-focus-outlines");
assert.equal(focus_outline_util.maybe_show_focus_outlines($container, "tab"), true);
assert.equal($container.hasClass("no-visible-focus-outlines"), false);
});
run_test("returns false when class already removed", () => {
const $container = $("#test-container");
// No class added — simulates second keypress.
assert.equal(focus_outline_util.maybe_show_focus_outlines($container, "tab"), false);
});
run_test("returns false for non-navigation key", () => {
const $container = $("#test-container");
$container.addClass("no-visible-focus-outlines");
assert.equal(focus_outline_util.maybe_show_focus_outlines($container, "enter"), false);
// Class should not be removed.
assert.equal($container.hasClass("no-visible-focus-outlines"), true);
});

View File

@@ -0,0 +1,104 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {FoldDict} = zrequire("fold_dict");
run_test("basic", () => {
const d = new FoldDict();
assert.equal(d.size, 0);
assert.deepEqual(d.keys().toArray(), []);
d.set("foo", "bar");
assert.equal(d.get("foo"), "bar");
assert.notEqual(d.size, 0);
d.set("foo", "baz");
assert.equal(d.get("foo"), "baz");
assert.equal(d.size, 1);
d.set("bar", "qux");
assert.equal(d.get("foo"), "baz");
assert.equal(d.get("bar"), "qux");
assert.equal(d.size, 2);
assert.equal(d.has("bar"), true);
assert.equal(d.has("baz"), false);
assert.deepEqual(d.keys().toArray(), ["foo", "bar"]);
assert.deepEqual(d.values().toArray(), ["baz", "qux"]);
assert.deepEqual(
[...d],
[
["foo", "baz"],
["bar", "qux"],
],
);
d.delete("bar");
assert.equal(d.has("bar"), false);
assert.strictEqual(d.get("bar"), undefined);
assert.deepEqual(d.keys().toArray(), ["foo"]);
const val = ["foo"];
const res = d.set("abc", val);
assert.strictEqual(res, d);
});
run_test("case insensitivity", () => {
const d = new FoldDict();
assert.deepEqual(d.keys().toArray(), []);
assert.ok(!d.has("foo"));
d.set("fOO", "Hello world");
assert.equal(d.get("foo"), "Hello world");
assert.ok(d.has("foo"));
assert.ok(d.has("FOO"));
assert.ok(!d.has("not_a_key"));
assert.deepEqual(d.keys().toArray(), ["fOO"]);
d.delete("Foo");
assert.equal(d.has("foo"), false);
assert.deepEqual(d.keys().toArray(), []);
});
run_test("clear", () => {
const d = new FoldDict();
function populate() {
d.set("fOO", 1);
assert.equal(d.get("foo"), 1);
d.set("bAR", 2);
assert.equal(d.get("bar"), 2);
}
populate();
assert.equal(d.size, 2);
d.clear();
assert.equal(d.get("fOO"), undefined);
assert.equal(d.get("bAR"), undefined);
assert.equal(d.size, 0);
// make sure it still works after clearing
populate();
assert.equal(d.size, 2);
});
run_test("undefined_keys", () => {
const d = new FoldDict();
assert.throws(() => d.has(undefined), {
name: "TypeError",
message: "Tried to call a FoldDict method with an undefined key.",
});
});

View File

@@ -0,0 +1,327 @@
"use strict";
const assert = require("node:assert/strict");
const {make_stream} = require("./lib/example_stream.cjs");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const dropdown_widget = mock_esm("../src/dropdown_widget");
let tippy_default_stub = noop;
let tippy_instance;
const tippy_function = (reference, options) => {
tippy_default_stub(reference, options);
tippy_instance = {destroy: noop};
return tippy_instance;
};
tippy_function.setDefaultProps = noop;
mock_esm("tippy.js", {
default: tippy_function,
});
const channel_folders = zrequire("channel_folders");
const stream_data = zrequire("stream_data");
const folder_dropdown_widget = zrequire("folder_dropdown_widget");
function initialize_folders() {
const frontend_folder = {
name: "Frontend",
description: "Channels for frontend discussions",
rendered_description: "<p>Channels for frontend discussions</p>",
creator_id: null,
date_created: 1596710000,
id: 1,
is_archived: false,
order: 0,
};
const backend_folder = {
name: "Backend",
description: "Channels for backend discussions",
rendered_description: "<p>Channels for backend discussions</p>",
creator_id: null,
date_created: 1596720000,
id: 2,
is_archived: false,
order: 1,
};
channel_folders.initialize({channel_folders: [frontend_folder, backend_folder]});
return {frontend_folder, backend_folder};
}
function initialize_streams(folders) {
stream_data.clear_subscriptions();
const stream_1 = make_stream({
stream_id: 1,
name: "Stream 1",
folder_id: folders.frontend_folder.id,
subscribed: true,
});
const stream_2 = make_stream({
stream_id: 2,
name: "Stream 2",
folder_id: folders.backend_folder.id,
subscribed: true,
});
const stream_3 = make_stream({
stream_id: 3,
name: "Stream 3",
folder_id: null,
subscribed: true,
});
stream_data.add_sub_for_tests(stream_1);
stream_data.add_sub_for_tests(stream_2);
stream_data.add_sub_for_tests(stream_3);
return {stream_1, stream_2, stream_3};
}
run_test("get_folder_filter_dropdown_options - with uncategorized channels", () => {
const folders = initialize_folders();
initialize_streams(folders);
// Test with no current value
let options = folder_dropdown_widget.get_folder_filter_dropdown_options();
assert.equal(options.length, 4); // Any folder + Uncategorized + 2 folders
// Check "Any folder" option (should be first)
assert.equal(options[0].name, "translated: Any folder");
assert.equal(
options[0].unique_id,
folder_dropdown_widget.FOLDER_FILTERS.ANY_FOLDER_DROPDOWN_OPTION,
);
assert.equal(options[0].bold_current_selection, false);
// Check "Uncategorized" option (should be second)
assert.equal(options[1].name, "translated: Uncategorized");
assert.equal(
options[1].unique_id,
folder_dropdown_widget.FOLDER_FILTERS.UNCATEGORIZED_DROPDOWN_OPTION,
);
assert.equal(options[1].bold_current_selection, false);
// Check folder options
assert.equal(options[2].name, "Frontend");
assert.equal(options[2].unique_id, folders.frontend_folder.id);
assert.equal(options[2].bold_current_selection, false);
assert.equal(options[3].name, "Backend");
assert.equal(options[3].unique_id, folders.backend_folder.id);
assert.equal(options[3].bold_current_selection, false);
// Test with current value set to a specific folder
options = folder_dropdown_widget.get_folder_filter_dropdown_options(folders.frontend_folder.id);
assert.equal(options[0].bold_current_selection, false); // Any folder not selected
assert.equal(options[1].bold_current_selection, false); // Uncategorized not selected
assert.equal(options[2].bold_current_selection, true); // Frontend selected
assert.equal(options[3].bold_current_selection, false); // Backend not selected
// Test with current value set to "Any folder"
options = folder_dropdown_widget.get_folder_filter_dropdown_options(
folder_dropdown_widget.FOLDER_FILTERS.ANY_FOLDER_DROPDOWN_OPTION,
);
assert.equal(options[0].bold_current_selection, true); // Any folder selected
assert.equal(options[1].bold_current_selection, false);
assert.equal(options[2].bold_current_selection, false);
assert.equal(options[3].bold_current_selection, false);
// Test with current value set to "Uncategorized"
options = folder_dropdown_widget.get_folder_filter_dropdown_options(
folder_dropdown_widget.FOLDER_FILTERS.UNCATEGORIZED_DROPDOWN_OPTION,
);
assert.equal(options[0].bold_current_selection, false);
assert.equal(options[1].bold_current_selection, true); // Uncategorized selected
assert.equal(options[2].bold_current_selection, false);
assert.equal(options[3].bold_current_selection, false);
});
run_test("get_folder_filter_dropdown_options - without uncategorized channels", () => {
const folders = initialize_folders();
stream_data.clear_subscriptions();
// Only add streams with folder_id (no uncategorized streams)
const stream_1 = make_stream({
stream_id: 1,
name: "Stream 1",
folder_id: folders.frontend_folder.id,
subscribed: true,
});
const stream_2 = make_stream({
stream_id: 2,
name: "Stream 2",
folder_id: folders.backend_folder.id,
subscribed: true,
});
stream_data.add_sub_for_tests(stream_1);
stream_data.add_sub_for_tests(stream_2);
const options = folder_dropdown_widget.get_folder_filter_dropdown_options();
// Should only have 3 options: Any folder + 2 folders (no Uncategorized)
assert.equal(options.length, 3);
assert.equal(options[0].name, "translated: Any folder");
assert.equal(options[1].name, "Frontend");
assert.equal(options[2].name, "Backend");
});
run_test("get_folder_filter_dropdown_options - empty folders", () => {
channel_folders.initialize({channel_folders: []});
stream_data.clear_subscriptions();
const options = folder_dropdown_widget.get_folder_filter_dropdown_options();
// Should only have "Any folder" option when there are no folders
assert.equal(options.length, 1);
assert.equal(options[0].name, "translated: Any folder");
});
run_test("create_folder_filter_dropdown_widget", () => {
const folders = initialize_folders();
initialize_streams(folders);
const $events_container = $.create("events-container");
let created_widget_params;
dropdown_widget.DropdownWidget = function (params) {
created_widget_params = params;
return {
render: noop,
value: () => -102,
};
};
const widget_name = "test_folder_filter";
const widget_selector = "#test_folder_filter_widget";
const item_click_callback = noop;
const default_id = folder_dropdown_widget.FOLDER_FILTERS.ANY_FOLDER_DROPDOWN_OPTION;
const widget = folder_dropdown_widget.create_folder_filter_dropdown_widget({
widget_name,
widget_selector,
item_click_callback,
$events_container,
default_id,
});
assert.ok(widget !== undefined);
assert.equal(widget.value(), default_id);
assert.equal(created_widget_params.widget_name, widget_name);
assert.equal(created_widget_params.widget_selector, widget_selector);
assert.equal(created_widget_params.item_click_callback, item_click_callback);
assert.equal(created_widget_params.$events_container, $events_container);
assert.equal(created_widget_params.unique_id_type, "number");
assert.equal(created_widget_params.default_id, default_id);
assert.equal(typeof created_widget_params.get_options, "function");
// Verify get_options function returns correct data
const options = created_widget_params.get_options();
assert.ok(options.length > 0);
assert.equal(options[0].name, "translated: Any folder");
});
run_test("update_tooltip_for_folder_filter - Any folder", () => {
initialize_folders();
let tippy_content;
let tippy_target;
const body_stub = {};
global.document = {body: body_stub};
tippy_default_stub = (element, options) => {
tippy_target = element;
tippy_content = options.content;
assert.equal(options.appendTo(), body_stub);
};
const $element = $.create("#folder_filter_widget");
$element[0]._tippy = {destroy: noop};
folder_dropdown_widget.update_tooltip_for_folder_filter(
"folder_filter_widget",
folder_dropdown_widget.FOLDER_FILTERS.ANY_FOLDER_DROPDOWN_OPTION,
);
assert.equal(tippy_target, $element[0]);
assert.equal(tippy_content, "translated: Filter by folder");
});
run_test("update_tooltip_for_folder_filter - Uncategorized", () => {
initialize_folders();
let tippy_content;
tippy_default_stub = (_element, options) => {
tippy_content = options.content;
};
const $element = $.create("#folder_filter_widget");
$element[0]._tippy = {destroy: noop};
folder_dropdown_widget.update_tooltip_for_folder_filter(
"folder_filter_widget",
folder_dropdown_widget.FOLDER_FILTERS.UNCATEGORIZED_DROPDOWN_OPTION,
);
assert.equal(tippy_content, "translated: Viewing uncategorized channels");
});
run_test("update_tooltip_for_folder_filter - specific folder", () => {
const folders = initialize_folders();
let tippy_content;
tippy_default_stub = (_element, options) => {
tippy_content = options.content;
};
const $element = $.create("#folder_filter_widget");
$element[0]._tippy = {destroy: noop};
folder_dropdown_widget.update_tooltip_for_folder_filter(
"folder_filter_widget",
folders.frontend_folder.id,
);
assert.equal(tippy_content, "translated: Viewing channels in Frontend");
});
run_test("update_tooltip_for_folder_filter - destroy previous tooltip", () => {
const folders = initialize_folders();
let destroy_called = false;
tippy_default_stub = () => {};
const $element = $.create("#folder_filter_widget");
$element[0]._tippy = {
destroy() {
destroy_called = true;
},
};
folder_dropdown_widget.update_tooltip_for_folder_filter(
"folder_filter_widget",
folders.frontend_folder.id,
);
assert.ok(destroy_called);
});
run_test("update_tooltip_for_folder_filter - no previous tooltip", () => {
const folders = initialize_folders();
tippy_default_stub = () => {};
$.create("#folder_filter_widget"); // No _tippy property
assert.doesNotThrow(() => {
folder_dropdown_widget.update_tooltip_for_folder_filter(
"folder_filter_widget",
folders.frontend_folder.id,
);
});
});

View File

@@ -0,0 +1,71 @@
"use strict";
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const gear_menu_util = zrequire("gear_menu_util");
const {set_realm} = zrequire("state_data");
const realm = make_realm();
set_realm(realm);
run_test("version_display_string", ({override}) => {
let expected_version_display_string;
// An official release
override(realm, "zulip_version", "5.6");
override(realm, "zulip_merge_base", "5.6");
expected_version_display_string = "translated: Zulip Server 5.6";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// An official beta
override(realm, "zulip_version", "6.0-beta1");
override(realm, "zulip_merge_base", "6.0-beta1");
expected_version_display_string = "translated: Zulip Server 6.0-beta1";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// An official release candidate
override(realm, "zulip_version", "6.0-rc1");
override(realm, "zulip_merge_base", "6.0-rc1");
expected_version_display_string = "translated: Zulip Server 6.0-rc1";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// The Zulip development environment
override(realm, "zulip_version", "6.0-dev+git");
override(realm, "zulip_merge_base", "6.0-dev+git");
expected_version_display_string = "translated: Zulip Server dev environment";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// A commit on Zulip's main branch.
override(realm, "zulip_version", "6.0-dev-1976-g4bb381fc80");
override(realm, "zulip_merge_base", "6.0-dev-1976-g4bb381fc80");
expected_version_display_string = "translated: Zulip Server 6.0-dev";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// A fork with 18 commits beyond Zulip's main branch.
override(realm, "zulip_version", "6.0-dev-1994-g93730766b0");
override(realm, "zulip_merge_base", "6.0-dev-1976-g4bb381fc80");
expected_version_display_string = "translated: Zulip Server 6.0-dev (modified)";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// A commit from the Zulip 5.x branch
override(realm, "zulip_version", "5.6+git-4-g385a408be5");
override(realm, "zulip_merge_base", "5.6+git-4-g385a408be5");
expected_version_display_string = "translated: Zulip Server 5.6 (patched)";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// A fork with 3 commits beyond the Zulip 5.x branch.
override(realm, "zulip_version", "5.6+git-4-g385a408be5");
override(realm, "zulip_merge_base", "5.6+git-7-abcda4235c2");
expected_version_display_string = "translated: Zulip Server 5.6 (modified)";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
// A fork of a Zulip release commit, not on 5.x branch.
override(realm, "zulip_version", "5.3-1-g7ed896c0db");
override(realm, "zulip_merge_base", "5.3");
expected_version_display_string = "translated: Zulip Server 5.3 (modified)";
assert.equal(gear_menu_util.version_display_string(), expected_version_display_string);
});

View File

@@ -0,0 +1,390 @@
"use strict";
const assert = require("node:assert/strict");
const message_link_test_cases = require("../../zerver/tests/fixtures/message_link_test_cases.json");
const {make_stream} = require("./lib/example_stream.cjs");
const {make_user} = require("./lib/example_user.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const hash_parser = zrequire("hash_parser");
const hash_util = zrequire("hash_util");
const stream_data = zrequire("stream_data");
const people = zrequire("people");
const spectators = zrequire("spectators");
const hamlet = make_user({
user_id: 15,
email: "hamlet@example.com",
full_name: "Hamlet",
});
people.add_active_user(hamlet, "server_events");
const frontend = make_stream({
stream_id: 99,
name: "frontend",
});
stream_data.add_sub_for_tests(frontend);
run_test("hash_util", () => {
// Test encode_operand and decode_operand
function encode_decode_operand(operator, operand, expected_val) {
const encode_result = hash_util.encode_operand({operator, operand});
assert.equal(encode_result, expected_val);
const new_operand = encode_result;
const decode_result = hash_util.decode_operand(operator, new_operand);
assert.equal(decode_result, operand);
}
let operator = "sender";
let operand = hamlet.user_id;
encode_decode_operand(operator, operand, "15-Hamlet");
operator = "channel";
operand = frontend.stream_id.toString();
encode_decode_operand(operator, operand, "99-frontend");
operator = "topic";
operand = "testing 123";
encode_decode_operand(operator, operand, "testing.20123");
});
run_test("test_get_hash_category", () => {
assert.deepEqual(hash_parser.get_hash_category("channels/subscribed"), "channels");
assert.deepEqual(hash_parser.get_hash_category("#settings/preferences"), "settings");
assert.deepEqual(hash_parser.get_hash_category("#drafts"), "drafts");
assert.deepEqual(hash_parser.get_hash_category("invites"), "invites");
window.location.hash = "#settings/profile";
assert.deepEqual(hash_parser.get_current_hash_category(), "settings");
});
run_test("test_get_hash_section", () => {
assert.equal(hash_parser.get_hash_section("channels/subscribed"), "subscribed");
assert.equal(hash_parser.get_hash_section("#settings/profile"), "profile");
assert.equal(hash_parser.get_hash_section("settings/10/general/"), "10");
assert.equal(hash_parser.get_hash_section("#drafts"), "");
assert.equal(hash_parser.get_hash_section(""), "");
window.location.hash = "#settings/profile";
assert.deepEqual(hash_parser.get_current_hash_section(), "profile");
});
run_test("get_current_nth_hash_section", () => {
window.location.hash = "#settings/profile";
assert.equal(hash_parser.get_current_nth_hash_section(0), "#settings");
assert.equal(hash_parser.get_current_nth_hash_section(1), "profile");
window.location.hash = "#settings/10/general";
assert.equal(hash_parser.get_current_nth_hash_section(0), "#settings");
assert.equal(hash_parser.get_current_nth_hash_section(1), "10");
assert.equal(hash_parser.get_current_nth_hash_section(2), "general");
assert.equal(hash_parser.get_current_nth_hash_section(3), "");
});
run_test("test_is_same_server_message_link", () => {
for (const message_link_test_case of message_link_test_cases) {
assert.equal(
hash_parser.is_same_server_message_link(message_link_test_case.message_link),
message_link_test_case.expected_output,
);
}
});
run_test("get_reload_hash", () => {
window.location.hash = "#settings/profile";
assert.equal(hash_util.get_reload_hash(), "settings/profile");
window.location.hash = "#test";
assert.equal(hash_util.get_reload_hash(), "test");
window.location.hash = "#";
assert.equal(hash_util.get_reload_hash(), "");
window.location.hash = "";
assert.equal(hash_util.get_reload_hash(), "");
});
run_test("test is_editing_stream", () => {
window.location.hash = "#channels/1/announce";
assert.equal(hash_parser.is_editing_stream(1), true);
assert.equal(hash_parser.is_editing_stream(2), false);
// url is missing name at end
window.location.hash = "#channels/1";
assert.equal(hash_parser.is_editing_stream(1), false);
window.location.hash = "#channels/bogus/bogus";
assert.equal(hash_parser.is_editing_stream(1), false);
window.location.hash = "#test/narrow";
assert.equal(hash_parser.is_editing_stream(1), false);
});
run_test("test_is_create_new_stream_narrow", () => {
window.location.hash = "#channels/new";
assert.equal(hash_parser.is_create_new_stream_narrow(), true);
window.location.hash = "#some/random/hash";
assert.equal(hash_parser.is_create_new_stream_narrow(), false);
});
run_test("test_is_subscribers_section_opened_for_stream", () => {
window.location.hash = "#channels/1/Design/subscribers";
assert.equal(hash_parser.is_subscribers_section_opened_for_stream(), true);
window.location.hash = "#channels/99/.EC.A1.B0.EB.A6.AC.EB.B2.95.20.F0.9F.98.8E/subscribers";
assert.equal(hash_parser.is_subscribers_section_opened_for_stream(), true);
window.location.hash = "#channels/random/subscribers";
assert.equal(hash_parser.is_subscribers_section_opened_for_stream(), false);
window.location.hash = "#some/random/place/subscribers";
assert.equal(hash_parser.is_subscribers_section_opened_for_stream(), false);
window.location.hash = "#";
assert.equal(hash_parser.is_subscribers_section_opened_for_stream(), false);
});
run_test("test_is_in_specified_hash_category", () => {
window.location.hash = "#channels/1/Design/subscribers";
assert.equal(hash_parser.is_in_specified_hash_category(["channels", "channel"]), true);
window.location.hash = "#channels/99/.EC.A1.B0.EB.A6.AC.EB.B2.95.20.F0.9F.98.8E/subscribers";
assert.equal(hash_parser.is_in_specified_hash_category(["channels", "channel"]), true);
window.location.hash = "#gro/channels/channel";
assert.equal(hash_parser.is_in_specified_hash_category(["stream", "channel", "group"]), false);
window.location.hash = "#some/stream/channel/group";
assert.equal(hash_parser.is_in_specified_hash_category(["stream", "channel", "group"]), false);
window.location.hash = "#some/stream/channel/group";
assert.equal(hash_parser.is_in_specified_hash_category([""]), false);
window.location.hash = "#some/stream/channel/group";
assert.equal(hash_parser.is_in_specified_hash_category([]), false);
});
run_test("test_parse_narrow", () => {
assert.deepEqual(hash_util.parse_narrow(["narrow", "stream", "99-frontend"]), [
{negated: false, operator: "channel", operand: frontend.stream_id.toString()},
]);
assert.deepEqual(hash_util.parse_narrow(["narrow", "-stream", "99-frontend"]), [
{negated: true, operator: "channel", operand: frontend.stream_id.toString()},
]);
assert.equal(hash_util.parse_narrow(["narrow", "BOGUS"]), undefined);
// For unknown channel IDs, we still parse it; it could be a valid channel we do
// not have access to. We'll end up showing "Invalid stream" in the navbar.
assert.deepEqual(hash_util.parse_narrow(["narrow", "stream", "42-bogus"]), [
{negated: false, operator: "channel", operand: "42"},
]);
// Empty string as a topic name is valid.
assert.deepEqual(hash_util.parse_narrow(["narrow", "stream", "99-frontend", "topic", ""]), [
{negated: false, operator: "channel", operand: frontend.stream_id.toString()},
{negated: false, operator: "topic", operand: ""},
]);
// Empty string used as an operand in other cases is invalid.
assert.deepEqual(
hash_util.parse_narrow(["narrow", "stream", "99-frontend", "topic", "", "near", ""]),
undefined,
);
// mentions:me in URL is converted to is:mentioned.
assert.deepEqual(hash_util.parse_narrow(["narrow", "mentions", "me"]), [
{negated: false, operator: "is", operand: "mentioned"},
]);
assert.deepEqual(hash_util.parse_narrow(["narrow", "-mentions", "me"]), [
{negated: true, operator: "is", operand: "mentioned"},
]);
});
run_test("test_channels_settings_edit_url", () => {
const sub = {
name: "research & development",
stream_id: 42,
};
assert.equal(
hash_util.channels_settings_edit_url(sub, "general"),
"#channels/42/research.20.26.20development/general",
);
});
run_test("test_by_conversation_and_time_url", () => {
let message = {
type: "stream",
stream_id: frontend.stream_id,
topic: "testing",
id: 42,
};
assert.equal(
hash_util.by_conversation_and_time_url(message),
"http://zulip.zulipdev.com/#narrow/channel/99-frontend/topic/testing/near/42",
);
message = {
type: "private",
display_recipient: [
{
id: hamlet.user_id,
},
],
id: 43,
};
assert.equal(
hash_util.by_conversation_and_time_url(message),
"http://zulip.zulipdev.com/#narrow/dm/15-dm/near/43",
);
});
run_test("test_search_public_streams_notice_url", () => {
function get_terms(url) {
return hash_util.parse_narrow(url.split("/"));
}
assert.equal(
hash_util.search_public_streams_notice_url(get_terms("#narrow/search/abc")),
"#narrow/channels/public/search/abc",
);
assert.equal(
hash_util.search_public_streams_notice_url(
get_terms("#narrow/has/link/has/image/has/attachment"),
),
"#narrow/channels/public/has/link/has/image/has/attachment",
);
assert.equal(
hash_util.search_public_streams_notice_url(get_terms("#narrow/sender/15")),
"#narrow/channels/public/sender/15-Hamlet",
);
});
run_test("test_current_hash_as_next", () => {
window.location.hash = "#foo";
assert.equal(spectators.current_hash_as_next(), "next=/%23foo");
});
run_test("get_link_hash", () => {
assert.equal(
hash_util.get_link_hash("/#narrow/channel/9-announce"),
"#narrow/channel/9-announce",
);
assert.equal(
hash_util.get_link_hash("https://chat.zulip.org/#narrow/channel/127-integrations/"),
"#narrow/channel/127-integrations/",
);
assert.equal(hash_util.get_link_hash("bad-url"), "");
});
run_test("test_is_overlay_hash", () => {
// All overlay categories should return true.
const overlay_hashes = [
"#streams/all",
"#channels/subscribed",
"#drafts",
"#groups",
"#settings/profile",
"#organization/profile",
"#invite",
"#keyboard-shortcuts",
"#message-formatting",
"#search-operators",
"#about-zulip",
"#scheduled",
"#reminders",
"#user/12345",
];
for (const hash of overlay_hashes) {
assert.equal(hash_parser.is_overlay_hash(hash), true);
}
// Extra path segments still match on the category.
assert.equal(hash_parser.is_overlay_hash("#channels/1/general"), true);
// Non-overlay hashes should return false.
assert.equal(hash_parser.is_overlay_hash("#narrow/channel/99-frontend"), false);
assert.equal(hash_parser.is_overlay_hash("#recent"), false);
assert.equal(hash_parser.is_overlay_hash("#feed"), false);
assert.equal(hash_parser.is_overlay_hash(""), false);
assert.equal(hash_parser.is_overlay_hash(undefined), false);
});
run_test("test_is_an_allowed_web_public_narrow", () => {
// The special case: "is" operator with "resolved" operand.
assert.equal(hash_parser.is_an_allowed_web_public_narrow("is", "resolved"), true);
// "is" with other operands is not allowed.
assert.equal(hash_parser.is_an_allowed_web_public_narrow("is", "starred"), false);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("is", "mentioned"), false);
// Allowed operators return true regardless of operand.
assert.equal(hash_parser.is_an_allowed_web_public_narrow("channel", "anything"), true);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("topic", "anything"), true);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("sender", "anything"), true);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("has", "anything"), true);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("search", "anything"), true);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("near", "anything"), true);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("id", "anything"), true);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("with", "anything"), true);
// Disallowed operators should return false.
assert.equal(hash_parser.is_an_allowed_web_public_narrow("dm", "someone"), false);
assert.equal(hash_parser.is_an_allowed_web_public_narrow("dm-including", "someone"), false);
});
run_test("test_is_spectator_compatible", () => {
// Non-narrow allowed hashes.
assert.equal(hash_parser.is_spectator_compatible(""), true);
assert.equal(hash_parser.is_spectator_compatible("#recent"), true);
assert.equal(hash_parser.is_spectator_compatible("#recent_topics"), true);
assert.equal(hash_parser.is_spectator_compatible("#feed"), true);
assert.equal(hash_parser.is_spectator_compatible("#all_messages"), true);
assert.equal(hash_parser.is_spectator_compatible("#keyboard-shortcuts"), true);
assert.equal(hash_parser.is_spectator_compatible("#message-formatting"), true);
assert.equal(hash_parser.is_spectator_compatible("#search-operators"), true);
assert.equal(hash_parser.is_spectator_compatible("#about-zulip"), true);
assert.equal(hash_parser.is_spectator_compatible("#topics"), true);
// Narrow hashes with allowed operators.
assert.equal(hash_parser.is_spectator_compatible("#narrow/channel/99-frontend"), true);
assert.equal(
hash_parser.is_spectator_compatible("#narrow/channel/99-frontend/topic/testing"),
true,
);
assert.equal(hash_parser.is_spectator_compatible("#narrow/sender/15"), true);
assert.equal(hash_parser.is_spectator_compatible("#narrow/has/link"), true);
assert.equal(hash_parser.is_spectator_compatible("#narrow/search/hello"), true);
assert.equal(hash_parser.is_spectator_compatible("#narrow/id/42"), true);
assert.equal(hash_parser.is_spectator_compatible("#narrow/near/100"), true);
assert.equal(hash_parser.is_spectator_compatible("#narrow/with/5"), true);
// Narrow hashes with disallowed operators.
assert.equal(hash_parser.is_spectator_compatible("#narrow/dm/15-dm"), false);
assert.equal(hash_parser.is_spectator_compatible("#narrow/is/starred"), false);
// Narrow with "is/resolved" is allowed via is_an_allowed_web_public_narrow.
assert.equal(hash_parser.is_spectator_compatible("#narrow/is/resolved"), true);
// Hashes that are neither narrow nor in the allowed list.
assert.equal(hash_parser.is_spectator_compatible("#settings/profile"), false);
assert.equal(hash_parser.is_spectator_compatible("#drafts"), false);
assert.equal(hash_parser.is_spectator_compatible("#channels/subscribed"), false);
});

View File

@@ -0,0 +1,474 @@
"use strict";
const assert = require("node:assert/strict");
const {make_stream} = require("./lib/example_stream.cjs");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {$} = require("./lib/zjquery.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
let $window_stub;
set_global("to_$", () => $window_stub);
set_global("document", "document-stub");
const history = set_global("history", {state: null});
const admin = mock_esm("../src/admin");
const drafts_overlay_ui = mock_esm("../src/drafts_overlay_ui");
const info_overlay = mock_esm("../src/info_overlay");
const message_viewport = mock_esm("../src/message_viewport");
const overlays = mock_esm("../src/overlays");
const popovers = mock_esm("../src/popovers");
const recent_view_ui = mock_esm("../src/recent_view_ui");
const settings = mock_esm("../src/settings");
mock_esm("../src/settings_data", {
user_can_create_public_streams: () => true,
});
const sidebar_ui = mock_esm("../src/sidebar_ui");
const spectators = mock_esm("../src/spectators", {
login_to_access() {},
});
const stream_settings_ui = mock_esm("../src/stream_settings_ui");
const ui_util = mock_esm("../src/ui_util");
const ui_report = mock_esm("../src/ui_report");
set_global("favicon", {});
const browser_history = zrequire("browser_history");
const people = zrequire("people");
const hash_util = zrequire("hash_util");
const hashchange = zrequire("hashchange");
const message_view = zrequire("../src/message_view");
const stream_data = zrequire("stream_data");
const {Filter} = zrequire("../src/filter");
const {initialize_user_settings} = zrequire("user_settings");
const user_settings = {};
initialize_user_settings({user_settings});
const devel_id = 100;
const devel = make_stream({
name: "devel",
stream_id: devel_id,
color: "blue",
subscribed: true,
});
stream_data.add_sub_for_tests(devel);
run_test("terms_round_trip", () => {
let terms;
let hash;
let narrow;
terms = [
{operator: "channel", operand: devel_id.toString()},
{operator: "topic", operand: "algol"},
];
hash = hash_util.search_terms_to_hash(terms);
assert.equal(hash, "#narrow/channel/100-devel/topic/algol");
narrow = hash_util.parse_narrow(hash.split("/"));
assert.deepEqual(narrow, [
{operator: "channel", operand: devel_id.toString(), negated: false},
{operator: "topic", operand: "algol", negated: false},
]);
terms = [
{operator: "channel", operand: devel_id.toString()},
{operator: "topic", operand: "visual c++", negated: true},
];
hash = hash_util.search_terms_to_hash(terms);
assert.equal(hash, "#narrow/channel/100-devel/-topic/visual.20c.2B.2B");
narrow = hash_util.parse_narrow(hash.split("/"));
assert.deepEqual(narrow, [
{operator: "channel", operand: devel_id.toString(), negated: false},
{operator: "topic", operand: "visual c++", negated: true},
]);
// test new encodings, where we have a stream id
const florida_id = 987;
const florida_stream = make_stream({
name: "Florida, USA",
stream_id: florida_id,
});
stream_data.add_sub_for_tests(florida_stream);
terms = [{operator: "channel", operand: florida_id.toString()}];
hash = hash_util.search_terms_to_hash(terms);
assert.equal(hash, "#narrow/channel/987-Florida.2C-USA");
narrow = hash_util.parse_narrow(hash.split("/"));
assert.deepEqual(narrow, [
{operator: "channel", operand: florida_id.toString(), negated: false},
]);
});
run_test("terms_trailing_slash", () => {
const hash = "#narrow/channel/100-devel/topic/algol/";
const narrow = hash_util.parse_narrow(hash.split("/"));
assert.deepEqual(narrow, [
{operator: "channel", operand: devel_id.toString(), negated: false},
{operator: "topic", operand: "algol", negated: false},
]);
});
run_test("people_slugs", () => {
let terms;
let hash;
let narrow;
const alice = {
email: "alice@example.com",
user_id: 42,
full_name: "Alice Smith",
};
people.add_active_user(alice, "server_events");
terms = [{operator: "sender", operand: alice.user_id}];
hash = hash_util.search_terms_to_hash(terms);
assert.equal(hash, "#narrow/sender/42-Alice-Smith");
narrow = hash_util.parse_narrow(hash.split("/"));
assert.deepEqual(narrow, [{operator: "sender", operand: alice.user_id, negated: false}]);
terms = [{operator: "dm", operand: [alice.user_id]}];
hash = hash_util.search_terms_to_hash(terms);
assert.equal(hash, "#narrow/dm/42-Alice-Smith");
narrow = hash_util.parse_narrow(hash.split("/"));
assert.deepEqual(narrow, [{operator: "dm", operand: [alice.user_id], negated: false}]);
});
function test_helper({override, override_rewire, change_tab}) {
let events = [];
let narrow_terms;
function stub(module, func_name) {
module[func_name] = () => {
events.push([module, func_name]);
};
}
function stub_with_args(module, func_name) {
module[func_name] = (...args) => {
events.push([module, func_name, args]);
};
}
stub_with_args(admin, "launch");
stub(admin, "build_page");
stub(drafts_overlay_ui, "launch");
stub(message_viewport, "stop_auto_scrolling");
stub(overlays, "close_for_hash_change");
stub(settings, "launch");
stub(settings, "build_page");
stub(stream_settings_ui, "launch");
stub(ui_util, "blur_active_element");
stub(ui_report, "error");
stub(spectators, "login_to_access");
if (change_tab) {
override_rewire(message_view, "show", (terms) => {
narrow_terms = terms;
events.push("message_view.show");
});
override(info_overlay, "show", (name) => {
events.push("info: " + name);
});
}
return {
clear_events() {
events = [];
},
assert_events(expected_events) {
assert.deepEqual(events, expected_events);
},
get_narrow_terms: () => narrow_terms,
};
}
run_test("hash_interactions", ({override, override_rewire}) => {
$window_stub = $.create("window-stub");
override(user_settings, "web_home_view", "recent");
const helper = test_helper({override, override_rewire, change_tab: true});
let recent_view_ui_shown = false;
override(recent_view_ui, "show", () => {
recent_view_ui_shown = true;
});
let hide_all_called = false;
override(popovers, "hide_all", () => {
hide_all_called = true;
});
let sidebar_hide_all_called = false;
override(sidebar_ui, "hide_all", () => {
sidebar_hide_all_called = true;
});
window.location.hash = "#unknown_hash";
browser_history.clear_for_testing();
hashchange.initialize();
// If it's an unknown hash it should show the home view.
assert.equal(recent_view_ui_shown, true);
assert.equal(hide_all_called, true);
assert.equal(sidebar_hide_all_called, true);
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
]);
window.location.hash = "#feed";
hide_all_called = false;
sidebar_hide_all_called = false;
helper.clear_events();
$window_stub.trigger("hashchange");
assert.equal(hide_all_called, true);
assert.equal(sidebar_hide_all_called, true);
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
"message_view.show",
]);
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
"message_view.show",
]);
// Test old "#recent_topics" hash redirects to "#recent".
recent_view_ui_shown = false;
window.location.hash = "#recent_topics";
helper.clear_events();
$window_stub.trigger("hashchange");
assert.equal(recent_view_ui_shown, true);
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
]);
assert.equal(window.location.hash, "#recent");
const denmark_id = 1;
stream_data.add_sub_for_tests(
make_stream({
subscribed: true,
name: "Denmark",
stream_id: denmark_id,
}),
);
window.location.hash = "#narrow/channel/Denmark";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
"message_view.show",
]);
let terms = helper.get_narrow_terms();
assert.equal(terms[0].operand, denmark_id.toString());
window.location.hash = "#narrow";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
"message_view.show",
]);
terms = helper.get_narrow_terms();
assert.equal(terms.length, 0);
page_params.is_spectator = true;
window.location.hash = "#narrow/is/resolved/has/reaction";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
"message_view.show",
]);
terms = helper.get_narrow_terms();
assert.equal(terms.length, 2);
// Test a narrow that spectators are not permitted to access.
window.location.hash = "#narrow/is/resolved/is/unread";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([[spectators, "login_to_access"]]);
page_params.is_spectator = false;
// Test an invalid narrow hash
window.location.hash = "#narrow/foo.foo";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
[ui_report, "error"],
]);
window.location.hash = "#channels/subscribed";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[stream_settings_ui, "launch"],
]);
recent_view_ui_shown = false;
window.location.hash = "#reload:send_after_reload=0...";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([]);
// If it's reload hash it shouldn't show the home view.
assert.equal(recent_view_ui_shown, false);
window.location.hash = "#keyboard-shortcuts/whatever";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([[overlays, "close_for_hash_change"], "info: keyboard-shortcuts"]);
window.location.hash = "#message-formatting/whatever";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([[overlays, "close_for_hash_change"], "info: message-formatting"]);
window.location.hash = "#search-operators/whatever";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([[overlays, "close_for_hash_change"], "info: search-operators"]);
window.location.hash = "#drafts";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[drafts_overlay_ui, "launch"],
]);
window.location.hash = "#settings/alert-words";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[settings, "build_page"],
[admin, "build_page"],
[settings, "launch"],
]);
window.location.hash = "#organization/users/active";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[settings, "build_page"],
[admin, "build_page"],
[admin, "launch", ["users", "active"]],
]);
let fixed_url;
override(history, "replaceState", (_state, _title, url) => {
fixed_url = url;
window.location.hash = new URL(url).hash;
});
window.location.hash = "#organization/user-list-admin";
// Check whether `user-list-admin` is redirect to `users`, we
// cannot test the exact hashchange here, since the section url
// takes effect in `admin.launch` and that's why we're checking
// the arguments passed to `admin.launch`.
helper.clear_events();
$window_stub.trigger("hashchange");
assert.equal(fixed_url, "http://zulip.zulipdev.com/#organization/users/active");
helper.assert_events([
[overlays, "close_for_hash_change"],
[settings, "build_page"],
[admin, "build_page"],
[admin, "launch", ["users", "active"]],
]);
helper.clear_events();
browser_history.exit_overlay();
helper.assert_events([[ui_util, "blur_active_element"]]);
});
run_test("update_hash_to_match_filter", ({override, override_rewire}) => {
const helper = test_helper({override, override_rewire});
let terms = [{operator: "is", operand: "dm"}];
blueslip.expect("error", "browser does not support pushState");
message_view.update_hash_to_match_filter(new Filter(terms));
helper.assert_events([[message_viewport, "stop_auto_scrolling"]]);
assert.equal(window.location.hash, "#narrow/is/dm");
let url_pushed;
override(history, "pushState", (_state, _title, url) => {
url_pushed = url;
});
terms = [{operator: "is", operand: "starred"}];
helper.clear_events();
message_view.update_hash_to_match_filter(new Filter(terms));
helper.assert_events([[message_viewport, "stop_auto_scrolling"]]);
assert.equal(url_pushed, "http://zulip.zulipdev.com/#narrow/is/starred");
terms = [{operator: "is", operand: "starred", negated: true}];
helper.clear_events();
message_view.update_hash_to_match_filter(new Filter(terms));
helper.assert_events([[message_viewport, "stop_auto_scrolling"]]);
assert.equal(url_pushed, "http://zulip.zulipdev.com/#narrow/-is/starred");
});
run_test("fail_incorrectly_cased_URL", ({override, override_rewire}) => {
browser_history.clear_for_testing();
override(popovers, "hide_all", noop);
override(sidebar_ui, "hide_all", noop);
const helper = test_helper({override, override_rewire, change_tab: false});
// We can receive URLs which contain operators that
// are not cased correctly. We don't have to handle them
// since this is not a good reason to increase the types
// of URLs that are valid on a Zulip realm.
window.location.hash = "#narrow/chAnnel/4-Denmark/topic/PLOTS/with/99";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
[ui_report, "error"],
]);
window.location.hash = "#narrow/channel/4-Denmark/tOPic/PLOTS/with/99";
helper.clear_events();
$window_stub.trigger("hashchange");
helper.assert_events([
[overlays, "close_for_hash_change"],
[message_viewport, "stop_auto_scrolling"],
[ui_report, "error"],
]);
});

786
web/tests/hotkey.test.cjs Normal file
View File

@@ -0,0 +1,786 @@
"use strict";
const assert = require("node:assert/strict");
const {make_realm} = require("./lib/example_realm.cjs");
const {mock_esm, set_global, with_overrides, zrequire} = require("./lib/namespace.cjs");
const {make_stub} = require("./lib/stub.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
// Important note on these tests:
//
// The way the Zulip hotkey tests work is as follows. First, we set
// up various contexts by monkey-patching the various hotkeys exports
// functions (like overlays.settings_open). Within that context, to
// test whether a given key (e.g. `x`) results in a specific function
// (e.g. `ui.foo()`), we fail to import any modules other than
// hotkey.ts so that accessing them will result in a ReferenceError.
//
// Then we create a stub `ui.foo`, and call the hotkey function. If
// it calls any external module other than `ui.foo`, it'll crash.
// Future work includes making sure it actually does call `ui.foo()`.
// All tests use the combined feed as the default narrow.
window.location.hash = "#feed";
set_global("navigator", {
platform: "",
});
// jQuery stuff should go away if we make an initialize() method.
set_global("document", {
hasFocus: () => false,
});
const activity_ui = mock_esm("../src/activity_ui");
const activity = zrequire("../src/activity");
const browser_history = mock_esm("../src/browser_history", {go_to_location() {}});
const compose_actions = mock_esm("../src/compose_actions");
const compose_reply = mock_esm("../src/compose_reply");
const condense = mock_esm("../src/condense");
const drafts_overlay_ui = mock_esm("../src/drafts_overlay_ui");
const emoji_picker = mock_esm("../src/emoji_picker", {
is_open: () => false,
start_picker_for_message_reaction() {},
});
const gear_menu = mock_esm("../src/gear_menu");
const info_overlay = mock_esm("../src/info_overlay");
const lightbox = mock_esm("../src/lightbox");
const list_util = mock_esm("../src/list_util");
const message_actions_popover = mock_esm("../src/message_actions_popover");
const message_edit = mock_esm("../src/message_edit");
const message_edit_history = mock_esm("../src/message_edit_history");
const message_lists = mock_esm("../src/message_lists");
const user_topics_ui = mock_esm("../src/user_topics_ui");
const message_view = mock_esm("../src/message_view");
const narrow_state = mock_esm("../src/narrow_state");
const navigate = mock_esm("../src/navigate");
const modals = mock_esm("../src/modals", {
any_active: () => false,
active_modal: () => undefined,
});
const overlays = mock_esm("../src/overlays", {
any_active: () => false,
settings_open: () => false,
streams_open: () => false,
lightbox_open: () => false,
drafts_open: () => false,
scheduled_messages_open: () => false,
reminders_open: () => false,
info_overlay_open: () => false,
message_edit_history_open: () => false,
});
const popover_menus_data = mock_esm("../src/popover_menus_data");
const popovers = mock_esm("../src/user_card_popover", {
user_sidebar: {
is_open: () => false,
},
message_user_card: {
is_open: () => false,
},
user_card: {
is_open: () => false,
},
});
const reactions = mock_esm("../src/reactions");
const read_receipts = mock_esm("../src/read_receipts");
const search = mock_esm("../src/search");
const settings_data = mock_esm("../src/settings_data");
const sidebar_ui = mock_esm("../src/sidebar_ui");
const stream_popover = mock_esm("../src/stream_popover");
const stream_settings_ui = mock_esm("../src/stream_settings_ui");
const user_status_ui = mock_esm("../src/user_status_ui");
mock_esm("../src/recent_view_ui", {
is_in_focus: () => false,
});
const spectators = zrequire("../src/spectators");
message_lists.current = {
visibly_empty() {
return false;
},
selected_id() {
return 42;
},
selected_row() {
const $emoji_message_control_button_container = $.create(
"emoji-message-control-button-container-stub",
);
$emoji_message_control_button_container.set_closest_results(
".message_control_button",
$.create("emoji-message-control-button-stub"),
);
const $row = $.create("selected-row-stub");
$row.set_find_results(".message-actions-menu-button", $.create("menu-button-stub"));
$row.set_find_results(
".message_controls .emoji-message-control-button-container",
$emoji_message_control_button_container,
);
return $row;
},
selected_message() {
return {
type: "stream",
stream_id: 2,
sent_by_me: true,
flags: ["read", "starred"],
};
},
get_row() {
return 101;
},
};
const emoji = zrequire("emoji");
const emoji_codes = zrequire("../../static/generated/emoji/emoji_codes.json");
const hotkey = zrequire("hotkey");
emoji.initialize({
realm_emoji: {},
emoji_codes,
});
const settings_config = zrequire("settings_config");
const {set_realm} = zrequire("state_data");
const realm = make_realm();
set_realm(realm);
function stubbing(module, func_name_to_stub, test_function) {
with_overrides(({override}) => {
const stub = make_stub();
override(module, func_name_to_stub, stub.f);
test_function(stub);
});
}
function test_while_not_editing_text(label, f) {
run_test(label, (helpers) => {
helpers.override_rewire(hotkey, "processing_text", () => false);
f(helpers);
});
}
run_test("mappings", () => {
function map_down(key, shiftKey, ctrlKey, metaKey, altKey) {
return hotkey.get_keydown_hotkey({
key,
shiftKey,
ctrlKey,
metaKey,
altKey,
});
}
// The next assertion protects against an iOS bug where we
// treat "!" as a hotkey, because iOS sends the wrong code.
assert.equal(map_down("!"), undefined);
// Test page-up does work.
assert.equal(map_down("PageUp").name, "page_up");
// Test other mappings.
assert.equal(map_down("Tab").name, "tab");
assert.equal(map_down("Tab", true).name, "shift_tab");
assert.equal(map_down("Escape").name, "escape");
assert.equal(map_down("ArrowLeft").name, "left_arrow");
assert.equal(map_down("Enter").name, "enter");
assert.equal(map_down("Delete").name, "delete");
assert.equal(map_down("Enter", true).name, "enter");
assert.equal(map_down("H", true).name, "view_edit_history");
assert.equal(map_down("L", true).name, "toggle_link_previews");
assert.equal(map_down("N", true).name, "narrow_to_next_unread_followed_topic");
assert.deepEqual(
map_down("V", true).map((item) => item.name),
["view_selected_stream", "toggle_read_receipts"],
);
assert.equal(map_down("Y", true).name, "set_status");
assert.equal(map_down("/").name, "search");
assert.equal(map_down("j").name, "vim_down");
assert.equal(map_down("[", false, true).name, "escape");
assert.equal(map_down("c", false, true).name, "copy_with_c");
assert.equal(map_down("k", false, true).name, "search_with_k");
assert.equal(map_down("@", true, true).name, "open_mentions_view");
assert.equal(map_down("s", false, true).name, "star_message");
assert.equal(map_down(".", false, true).name, "narrow_to_compose_target");
assert.equal(map_down("p", false, true).name, "print");
assert.equal(map_down("p", false, false, false, true).name, "toggle_compose_preview"); // Alt + P
assert.equal(map_down("+", false).name, "thumbs_up_emoji");
assert.equal(map_down("+", true).name, "thumbs_up_emoji");
// More negative tests.
assert.equal(map_down("Escape", true), undefined);
assert.equal(map_down("v", false, true), undefined);
assert.equal(map_down("z", false, true), undefined);
assert.equal(map_down("t", false, true), undefined);
assert.equal(map_down("r", false, true), undefined);
assert.equal(map_down("o", false, true), undefined);
assert.equal(map_down("a", false, true), undefined);
assert.equal(map_down("f", false, true), undefined);
assert.equal(map_down("h", false, true), undefined);
assert.equal(map_down("x", false, true), undefined);
assert.equal(map_down("n", false, true), undefined);
assert.equal(map_down("m", false, true), undefined);
assert.equal(map_down("c", false, false, true), undefined);
assert.equal(map_down("k", false, false, true), undefined);
assert.equal(map_down("s", false, false, true), undefined);
assert.equal(map_down("p", false, false, true), undefined);
assert.equal(map_down("K", true, true), undefined);
assert.equal(map_down("S", true, true), undefined);
// Ctrl+Shift+P is not intercepted; in Firefox it opens a
// private browsing window.
assert.equal(map_down("P", true, true), undefined);
assert.equal(map_down("[", true, true, false), undefined);
assert.equal(map_down("P", true, false, false, true), undefined);
assert.equal(map_down("+", false, true), undefined);
// Cmd tests for MacOS
navigator.platform = "MacIntel";
assert.equal(map_down("[", false, true, false).name, "escape");
assert.equal(map_down("[", false, false, true), undefined);
assert.equal(map_down("c", false, false, true).name, "copy_with_c");
assert.equal(map_down("c", false, true, true), undefined);
assert.equal(map_down("c", false, true, false), undefined);
assert.equal(map_down("k", false, false, true).name, "search_with_k");
assert.equal(map_down("k", false, true, false), undefined);
// On macOS, browsers report the unshifted key when Cmd+Shift
// are both held (e.g. Cmd+Shift+2 gives e.key="2", not "@"),
// so we fall back to `e.code` to recover the intended symbol.
assert.equal(
hotkey.get_keydown_hotkey({
key: "2",
code: "Digit2",
shiftKey: true,
metaKey: true,
}).name,
"open_mentions_view",
);
assert.equal(map_down("@", true, true, false), undefined);
assert.equal(map_down("s", false, false, true).name, "star_message");
assert.equal(map_down("s", false, true, false), undefined);
assert.equal(map_down(".", false, false, true).name, "narrow_to_compose_target");
assert.equal(map_down(".", false, true, false), undefined);
assert.equal(map_down("p", false, false, true).name, "print");
assert.equal(map_down("p", false, true, false), undefined);
// Reset platform
navigator.platform = "";
// Caps Lock doesn't interfere with shortcuts.
assert.equal(map_down("A").name, "open_combined_feed");
assert.equal(map_down("A", true).name, "stream_cycle_backward");
assert.equal(map_down("C", false, true).name, "copy_with_c");
assert.equal(map_down("P", false, false, false, true).name, "toggle_compose_preview");
});
run_test("mappings non-latin keyboard", () => {
// This test replicates the logic of the "mappings" test above
// but uses a non-Latin (Russian) keyboard layout to verify that
// hotkeys work irrespective of the keyboard layout.
// Layout used: https://kbdlayout.info/kbdru/overview+virtualkeys?arrangement=ANSI104
function map_down(key, code, shiftKey, ctrlKey, metaKey, altKey) {
return hotkey.get_keydown_hotkey({
key,
code,
shiftKey,
ctrlKey,
metaKey,
altKey,
});
}
// Test mappings.
assert.equal(map_down("Р", "KeyH", true).name, "view_edit_history");
assert.equal(map_down("Т", "KeyN", true).name, "narrow_to_next_unread_followed_topic");
assert.deepEqual(
map_down("М", "KeyV", true).map((item) => item.name),
["view_selected_stream", "toggle_read_receipts"],
);
assert.equal(map_down("Н", "KeyY", true).name, "set_status");
assert.equal(map_down("о", "KeyJ").name, "vim_down");
assert.equal(map_down("х", "BracketLeft", false, true).name, "escape");
assert.equal(map_down("с", "KeyC", false, true).name, "copy_with_c");
assert.equal(map_down("л", "KeyK", false, true).name, "search_with_k");
assert.equal(map_down("@", "Digit2", true, true).name, "open_mentions_view");
assert.equal(map_down("ы", "KeyS", false, true).name, "star_message");
assert.equal(map_down("з", "KeyP", false, true).name, "print");
assert.equal(map_down("з", "KeyP", false, false, false, true).name, "toggle_compose_preview");
// More negative tests.
assert.equal(map_down("м", "KeyV", false, true), undefined);
assert.equal(map_down("я", "KeyZ", false, true), undefined);
assert.equal(map_down("е", "KeyT", false, true), undefined);
assert.equal(map_down("к", "KeyR", false, true), undefined);
assert.equal(map_down("щ", "KeyO", false, true), undefined);
assert.equal(map_down("ф", "KeyA", false, true), undefined);
assert.equal(map_down("а", "KeyF", false, true), undefined);
assert.equal(map_down("р", "KeyH", false, true), undefined);
assert.equal(map_down("ч", "KeyX", false, true), undefined);
assert.equal(map_down("т", "KeyN", false, true), undefined);
assert.equal(map_down("ь", "KeyM", false, true), undefined);
assert.equal(map_down("с", "KeyC", false, false, true), undefined);
assert.equal(map_down("л", "KeyK", false, false, true), undefined);
assert.equal(map_down("ы", "KeyS", false, false, true), undefined);
assert.equal(map_down("з", "KeyP", false, false, true), undefined);
assert.equal(map_down("Л", "KeyK", true, true), undefined);
assert.equal(map_down("Ы", "KeyS", true, true), undefined);
// Ctrl+Shift+P is not intercepted; in Firefox it opens a
// private browsing window.
assert.equal(map_down("З", "KeyP", true, true), undefined);
assert.equal(map_down("Х", "BracketLeft", true, true, false), undefined);
assert.equal(map_down("З", "KeyP", true, false, false, true), undefined);
// Cmd tests for MacOS
navigator.platform = "MacIntel";
assert.equal(map_down("х", "BracketLeft", false, true, false).name, "escape");
assert.equal(map_down("х", "BracketLeft", false, false, true), undefined);
assert.equal(map_down("с", "KeyC", false, false, true).name, "copy_with_c");
assert.equal(map_down("с", "KeyC", false, true, true), undefined);
assert.equal(map_down("с", "KeyC", false, true, false), undefined);
assert.equal(map_down("л", "KeyK", false, false, true).name, "search_with_k");
assert.equal(map_down("л", "KeyK", false, true, false), undefined);
assert.equal(map_down("@", "Digit2", true, false, true).name, "open_mentions_view");
assert.equal(map_down("@", "Digit2", true, true, false), undefined);
assert.equal(map_down("ы", "KeyS", false, false, true).name, "star_message");
assert.equal(map_down("ы", "KeyS", false, true, false), undefined);
assert.equal(map_down("з", "KeyP", false, false, true).name, "print");
assert.equal(map_down("з", "KeyP", false, true, false), undefined);
// Reset platform
navigator.platform = "";
// Caps Lock doesn't interfere with shortcuts.
assert.equal(map_down("Ф", "KeyA").name, "open_combined_feed");
assert.equal(map_down("Ф", "KeyA", true).name, "stream_cycle_backward");
assert.equal(map_down("С", "KeyC", false, true).name, "copy_with_c");
assert.equal(map_down("З", "KeyP", false, false, false, true).name, "toggle_compose_preview");
});
function process(s, shiftKey) {
const e = {
key: s,
shiftKey,
};
try {
return hotkey.process_keydown(e);
} catch (error) /* istanbul ignore next */ {
// An exception will be thrown here if a different
// function is called than the one declared. Try to
// provide a useful error message.
// add a newline to separate from other console output.
console.log('\nERROR: Mapping for character "' + e.key + '" does not match tests.');
throw error;
}
}
function assert_mapping(c, module, func_name, shiftKey) {
stubbing(module, func_name, (stub) => {
assert.ok(process(c, shiftKey));
assert.equal(stub.num_calls, 1);
});
}
function assert_unmapped(s) {
for (const c of s) {
const shiftKey = /^[A-Z]$/.test(c);
assert.equal(process(c, shiftKey), false);
}
}
function test_normal_typing() {
assert_unmapped("abcdefghijklmnopqrsuvwxyz");
assert_unmapped(" ");
assert_unmapped("[]\\.,;");
assert_unmapped("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
assert_unmapped('~!@#$%^*()_+{}:"<>');
}
run_test("unmapped keys return false easily", () => {
// Unmapped keys should immediately return false, without
// calling any functions outside of hotkey.ts.
// (unless we are editing text)
assert_unmapped("bfo");
assert_unmapped("BEFOQTWXZ");
});
run_test("allow normal typing when editing text", ({override, override_rewire}) => {
// All letters should return false if we are composing text.
override_rewire(hotkey, "processing_text", () => true);
let settings_open;
let any_active;
let info_overlay_open;
override(overlays, "any_active", () => any_active);
override(overlays, "settings_open", () => settings_open);
override(overlays, "info_overlay_open", () => info_overlay_open);
$.set_results(".navbar-item:focus", []);
for (settings_open of [true, false]) {
for (any_active of [true, false]) {
for (info_overlay_open of [true, false]) {
test_normal_typing();
}
}
}
});
run_test("Ctrl+@ opens mentions view even while editing text", ({override_rewire}) => {
// Ctrl+@ / Cmd+@ should open the mentions view regardless of
// whether the focus is in a text input, matching the behavior
// of Ctrl+K (search) and Ctrl+S (star message).
override_rewire(hotkey, "processing_text", () => true);
stubbing(browser_history, "go_to_location", (stub) => {
assert.ok(hotkey.process_keydown({key: "@", shiftKey: true, ctrlKey: true}));
assert.equal(stub.num_calls, 1);
assert.equal(stub.last_call_args[0], "#narrow/is/mentioned");
});
});
test_while_not_editing_text("streams", ({override}) => {
settings_data.user_can_create_private_streams = () => true;
delete settings_data.user_can_create_public_streams;
delete settings_data.user_can_create_web_public_streams;
override(overlays, "streams_open", () => true);
override(overlays, "any_active", () => true);
assert_mapping("S", stream_settings_ui, "keyboard_sub", true);
assert_mapping("V", stream_settings_ui, "view_stream", true);
assert_mapping("n", stream_settings_ui, "open_create_stream");
settings_data.user_can_create_private_streams = () => false;
settings_data.user_can_create_public_streams = () => false;
settings_data.user_can_create_web_public_streams = () => false;
assert_unmapped("n");
});
test_while_not_editing_text("basic mappings", () => {
assert_mapping("?", browser_history, "go_to_location");
assert_mapping("/", search, "initiate_search");
assert_mapping("w", activity_ui, "initiate_search");
assert_mapping("q", sidebar_ui, "initiate_search");
assert_mapping("A", message_view, "stream_cycle_backward", true);
assert_mapping("D", message_view, "stream_cycle_forward", true);
assert_mapping("c", compose_actions, "start");
assert_mapping("x", compose_actions, "start");
assert_mapping("P", message_view, "show", true);
assert_mapping("g", gear_menu, "toggle");
assert_mapping("Y", user_status_ui, "open_user_status_modal", true);
});
test_while_not_editing_text("drafts open", ({override}) => {
override(overlays, "any_active", () => true);
override(overlays, "drafts_open", () => true);
assert_mapping("d", overlays, "close_overlay");
});
test_while_not_editing_text("drafts closed w/other overlay", ({override}) => {
override(overlays, "any_active", () => true);
override(overlays, "drafts_open", () => false);
test_normal_typing();
});
test_while_not_editing_text("drafts closed launch", ({override}) => {
override(overlays, "any_active", () => false);
assert_mapping("d", browser_history, "go_to_location");
});
run_test("print hotkey", ({override}) => {
// With an informational overlay open, Ctrl+P prints the full
// contents of the overlay's current pane.
override(overlays, "info_overlay_open", () => true);
stubbing(info_overlay, "print_current_pane", (stub) => {
assert.ok(hotkey.process_keydown({key: "p", ctrlKey: true}));
assert.equal(stub.num_calls, 1);
});
// Everywhere else, the browser's print dialog is not intercepted.
override(overlays, "info_overlay_open", () => false);
assert.equal(hotkey.process_keydown({key: "p", ctrlKey: true}), false);
});
run_test("modal open", ({override}) => {
override(modals, "any_active", () => true);
test_normal_typing();
});
test_while_not_editing_text("misc", ({override, disallow}) => {
// Next, test keys that only work on a selected message.
const message_view_only_keys = "@+>RjJkKsuvVi:GH";
// Check that they do nothing without a selected message
with_overrides(({override}) => {
override(message_lists.current, "visibly_empty", () => true);
assert_unmapped(message_view_only_keys);
});
// Check that they do nothing while in the settings overlay
with_overrides(({override}) => {
override(overlays, "settings_open", () => true);
assert_unmapped("@*+->rRjJkKsSuvVi:GMH");
});
// TODO: Similar check for being in the subs page
assert_mapping("@", compose_reply, "reply_with_mention");
assert_mapping("+", reactions, "toggle_emoji_reaction");
// Without an existing emoji reaction, this next one will only
// call get_message_reactions, so we verify just that.
assert_mapping("=", reactions, "get_message_reactions");
assert_mapping("-", condense, "toggle_collapse");
assert_mapping("r", compose_reply, "respond_to_message");
assert_mapping("R", compose_reply, "respond_to_message", true);
assert_mapping("j", navigate, "down");
assert_mapping("J", navigate, "page_down", true);
assert_mapping("k", navigate, "up");
assert_mapping("K", navigate, "page_up", true);
assert_mapping("u", popovers, "toggle_sender_info");
assert_mapping("i", message_actions_popover, "toggle_message_actions_menu");
assert_mapping(":", emoji_picker, "start_picker_for_message_reaction", true);
assert_mapping(">", compose_reply, "quote_messages");
assert_mapping("<", compose_reply, "quote_messages");
assert_mapping("e", message_edit, "start");
override(
realm,
"realm_message_edit_history_visibility_policy",
settings_config.message_edit_history_visibility_policy_values.always.code,
);
override(message_lists.current, "selected_message", () => ({
id: 1,
type: "stream",
last_edit_timestamp: 123,
}));
assert_mapping("H", message_edit_history, "fetch_and_render_message_history", true);
override(message_lists.current, "selected_message", () => ({
id: 2,
type: "stream",
last_moved_timestamp: 123,
}));
assert_mapping("H", message_edit_history, "fetch_and_render_message_history", true);
override(message_lists.current, "selected_message", () => ({
id: 3,
type: "stream",
}));
assert_unmapped("H");
override(message_lists.current, "selected_message", () => ({
id: 4,
type: "stream",
}));
override(popover_menus_data, "can_toggle_link_previews", () => true);
assert_mapping("L", message_actions_popover, "toggle_hide_link_previews", true);
// The shortcut is still handled when previews cannot be toggled.
override(popover_menus_data, "can_toggle_link_previews", () => false);
disallow(message_actions_popover, "toggle_hide_link_previews");
assert.ok(process("L", true));
override(narrow_state, "narrowed_by_topic_reply", () => true);
assert_mapping("s", message_view, "narrow_by_recipient");
override(narrow_state, "narrowed_by_topic_reply", () => false);
override(narrow_state, "narrowed_by_pm_reply", () => true);
assert_unmapped("s");
override(narrow_state, "narrowed_by_topic_reply", () => false);
override(narrow_state, "narrowed_by_pm_reply", () => false);
assert_mapping("s", message_view, "narrow_by_topic");
override(message_edit, "can_move_message", () => true);
assert_mapping("m", stream_popover, "build_move_topic_to_stream_popover");
override(message_edit, "can_move_message", () => false);
assert_unmapped("m");
assert_mapping("V", read_receipts, "show_user_list", true);
override(modals, "any_active", () => true);
override(modals, "active_modal", () => "#read_receipts_modal");
assert_mapping("V", read_receipts, "hide_user_list", true);
});
test_while_not_editing_text("lightbox overlay open", ({override}) => {
override(overlays, "any_active", () => true);
override(overlays, "lightbox_open", () => true);
assert_mapping("v", overlays, "close_overlay");
});
test_while_not_editing_text("lightbox closed w/other overlay open", ({override}) => {
override(overlays, "any_active", () => true);
override(overlays, "lightbox_open", () => false);
test_normal_typing();
});
test_while_not_editing_text("v w/no overlays", ({override}) => {
override(overlays, "any_active", () => false);
assert_mapping("v", lightbox, "show_from_selected_message");
});
run_test("emoji picker", ({override}) => {
override(emoji_picker, "is_open", () => true);
assert_mapping(":", emoji_picker, "navigate", true);
});
test_while_not_editing_text("G/M keys", () => {
// TODO: move
assert_mapping("G", navigate, "to_end", true);
assert_mapping("M", user_topics_ui, "toggle_topic_visibility_policy", true);
});
test_while_not_editing_text("n/p keys", () => {
// Test keys that work when a message is selected and
// also when the message list is empty.
assert_mapping("n", message_view, "narrow_to_next_topic");
assert_mapping("p", message_view, "narrow_to_next_pm_string");
assert_mapping("n", message_view, "narrow_to_next_topic");
});
test_while_not_editing_text("narrow next unread followed topic", () => {
assert_mapping("N", message_view, "narrow_to_next_topic", true);
});
test_while_not_editing_text("motion_keys", () => {
$.set_results(".navbar-item:focus", []);
const keys = {
down_arrow: "ArrowDown",
end: "End",
home: "Home",
left_arrow: "ArrowLeft",
right_arrow: "ArrowRight",
page_up: "PageUp",
page_down: "PageDown",
spacebar: " ",
up_arrow: "ArrowUp",
};
function process(name) {
const e = {
key: keys[name],
};
try {
return hotkey.process_keydown(e);
} catch (error) /* istanbul ignore next */ {
// An exception will be thrown here if a different
// function is called than the one declared. Try to
// provide a useful error message.
// add a newline to separate from other console output.
console.log('\nERROR: Mapping for character "' + e.key + '" does not match tests.');
throw error;
}
}
function assert_unmapped(name) {
assert.equal(process(name), false);
}
function assert_mapping(key_name, module, func_name) {
stubbing(module, func_name, (stub) => {
assert.ok(process(key_name));
assert.equal(stub.num_calls, 1);
});
}
list_util.inside_list = () => false;
message_lists.current.visibly_empty = () => true;
overlays.settings_open = () => false;
overlays.streams_open = () => false;
overlays.lightbox_open = () => false;
assert_unmapped("down_arrow");
assert_unmapped("end");
assert_unmapped("home");
assert_unmapped("page_up");
assert_unmapped("page_down");
assert_unmapped("spacebar");
assert_unmapped("up_arrow");
list_util.inside_list = () => true;
assert_mapping("up_arrow", list_util, "go_up");
assert_mapping("down_arrow", list_util, "go_down");
list_util.inside_list = () => false;
message_lists.current.visibly_empty = () => false;
assert_mapping("down_arrow", navigate, "down");
assert_mapping("end", navigate, "to_end");
assert_mapping("home", navigate, "to_home");
assert_mapping("left_arrow", message_edit, "edit_last_sent_message");
assert_mapping("page_up", navigate, "page_up");
assert_mapping("page_down", navigate, "page_down");
assert_mapping("spacebar", navigate, "page_down");
assert_mapping("up_arrow", navigate, "up");
overlays.info_overlay_open = () => true;
assert_unmapped("down_arrow");
assert_unmapped("up_arrow");
overlays.info_overlay_open = () => false;
overlays.streams_open = () => true;
assert_mapping("up_arrow", stream_settings_ui, "switch_rows");
assert_mapping("down_arrow", stream_settings_ui, "switch_rows");
delete overlays.streams_open;
overlays.lightbox_open = () => true;
assert_mapping("left_arrow", lightbox, "prev");
assert_mapping("right_arrow", lightbox, "next");
delete overlays.lightbox_open;
overlays.settings_open = () => true;
assert_unmapped("end");
assert_unmapped("home");
assert_unmapped("left_arrow");
assert_unmapped("page_up");
assert_unmapped("page_down");
assert_unmapped("spacebar");
delete overlays.settings_open;
delete overlays.any_active;
overlays.drafts_open = () => true;
assert_mapping("up_arrow", drafts_overlay_ui, "handle_keyboard_events");
assert_mapping("down_arrow", drafts_overlay_ui, "handle_keyboard_events");
delete overlays.any_active;
delete overlays.drafts_open;
});
run_test("test new user input hook called", () => {
let hook_called = false;
activity.register_on_new_user_input_hook(() => {
hook_called = true;
});
// Currently, "b" is not a valid hotkey.
// But it serves our purpose here to verify
// `hook_called` on keydown.
hotkey.process_keydown({key: "b"});
assert.ok(hook_called);
});
test_while_not_editing_text("e shortcut works for anonymous users", ({override_rewire}) => {
page_params.is_spectator = true;
const stub = make_stub();
override_rewire(spectators, "login_to_access", stub.f);
overlays.any_active = () => false;
overlays.settings_open = () => false;
const e = {
key: "e",
};
stubbing(message_edit, "start", (stub) => {
hotkey.process_keydown(e);
assert.equal(stub.num_calls, 1);
});
assert.equal(stub.num_calls, 0, "login_to_access should not be called for 'e' shortcut");
// Fake call to avoid warning about unused stub.
spectators.login_to_access();
assert.equal(stub.num_calls, 1);
});

165
web/tests/i18n.test.cjs Normal file
View File

@@ -0,0 +1,165 @@
"use strict";
const assert = require("node:assert/strict");
const _ = require("lodash");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {page_params} = require("./lib/zpage_params.cjs");
// We download our translations in `page_params` (which
// are for the user's chosen language), so we simulate
// that here for the tests.
page_params.request_language = "en";
page_params.translation_data = {
"Quote message": "Citer le message",
"Copy link to message": "Copier le lien vers le message",
"Notification triggers": "Déclencheurs de notification",
"You subscribed to channel {name}": "Vous n'êtes pas abonnés au canal {name}",
"<p>The channel <b>{name}</b> does not exist.</p><p>Manage your subscriptions <z-link>on your Channels page</z-link>.</p>":
"<p>Le canal <b>{name}</b> n'existe pas.</p><p>Gérez vos abonnements <z-link>sur votre page canaux</z-link>.</p>",
};
// `i18n.ts` initializes FormatJS and is imported by
// `templates.ts`.
const {$t, $t_html, get_language_name, get_language_list_columns, initialize} = zrequire("i18n");
run_test("$t", () => {
// Normally the id would be provided by babel-plugin-formatjs, but
// this test file is not processed by Babel.
assert.equal($t({id: "Quote message", defaultMessage: "Quote message"}), "Citer le message");
assert.equal(
$t(
{
id: "You subscribed to channel {name}",
defaultMessage: "You subscribed to channel {name}",
},
{name: "l'abonnement"},
),
"Vous n'êtes pas abonnés au canal l'abonnement",
);
});
run_test("$t_html", () => {
assert.equal(
$t_html(
{
id: "<p>The channel <b>{name}</b> does not exist.</p><p>Manage your subscriptions <z-link>on your Channels page</z-link>.</p>",
defaultMessage:
"<p>The channel <b>{name}</b> does not exist.</p><p>Manage your subscriptions <z-link>on your Channels page</z-link>.</p>",
},
{
name: "l'abonnement",
"z-link": (content_html) => `<a href='#channels/all'>${content_html.join("")}</a>`,
},
),
"<p>Le canal <b>l&#39;abonnement</b> n'existe pas.</p><p>Gérez vos abonnements <a href='#channels/all'>sur votre page canaux</a>.</p>",
);
});
run_test("t_tag", ({mock_template}) => {
// The Quote/Forward labels reach this template already translated, so
// assert on a label the template still translates itself.
const args = {
message_id: "99",
should_display_quote_message: true,
quote_message_menu_item: "Citer le message",
forward_message_menu_item: "Transférer le message",
show_quote_and_forward_hotkey_hints: true,
editability_menu_item: "Edit message",
conversation_time_url:
"http://zulip.zulipdev.com/#narrow/channel/101-devel/topic/testing/near/99",
};
mock_template("popovers/message_actions_popover.hbs", true, (data, html) => {
assert.equal(data, args);
assert.ok(html.includes("Copier le lien vers le message"));
return "<message-actions-popover-stub>";
});
require("../templates/popovers/message_actions_popover.hbs")(args);
});
run_test("{{#tr}} to tag for translation", ({mock_template}) => {
const args = {
general_settings: [],
notification_settings: {
desktop_notification_settings: [],
mobile_notification_settings: [],
email_message_notification_settings: [],
other_email_settings: [],
},
custom_stream_specific_notification_settings: [],
email_notifications_batching_period_values: [],
settings_object: {
available_notification_sounds: [],
},
settings_label: {
desktop_icon_count_display:
"Unread count badge (appears in desktop sidebar and browser tab)",
realm_name_in_email_notifications_policy:
"Include organization name in subject of message notification emails",
automatically_follow_topics_policy: "Automatically follow topics",
automatically_unmute_topics_in_muted_streams_policy:
"Automatically unmute topics in muted channels",
},
automatically_follow_topics_policy_values: {},
automatically_unmute_topics_in_muted_streams_policy_values: {},
resolved_topic_notice_auto_read_policy_values: {},
desktop_icon_count_display_values: {},
realm_name_in_email_notifications_policy_values: {},
};
// We're actually testing `notification_settings.hbs` here which
// is imported as a partial in the file below. We want to test
// the partial handling logic in `templates.ts`, that's why we
// test the file below instead of directly testing
// `notification_settings.hbs`.
mock_template("settings/user_notification_settings.hbs", true, (data, html) => {
assert.equal(data, args);
assert.ok(html.includes("Déclencheurs de notification"));
return "<user-notification-settings-stub>";
});
require("../templates/settings/user_notification_settings.hbs")(args);
});
run_test("language_list", () => {
const language_list = [
{code: "bqi", locale: "bqi", name: "Luri (Bakhtiari)", percent_translated: 5},
{code: "cy", locale: "cy", name: "Cymraeg", percent_translated: 36},
{code: "en", locale: "en", name: "English"},
{code: "en-gb", locale: "en_GB", name: "British English", percent_translated: 99},
{code: "id", locale: "id", name: "Bahasa Indonesia", percent_translated: 32},
{code: "mn", locale: "mn", name: "Mongolian", percent_translated: 53},
{code: "pt", locale: "pt", name: "Português", percent_translated: 81},
{code: "si", locale: "si", name: "Sinhala", percent_translated: 20},
{code: "zh-hans", locale: "zh_Hans", name: "简体中文", percent_translated: 86},
];
initialize({language_list});
assert.equal(get_language_name("en"), "English");
const successful_formatted_list = [
{code: "bqi", name_with_percent: "Bakhtiari (5%)", selected: false},
{code: "cy", name_with_percent: "Cymraeg (36%)", selected: false},
{code: "en", name_with_percent: "English (United States)", selected: true},
{code: "en-gb", name_with_percent: "English (United Kingdom) (99%)", selected: false},
{code: "id", name_with_percent: "Indonesia (32%)", selected: false},
{code: "mn", name_with_percent: "Монгол (53%)", selected: false},
{code: "pt", name_with_percent: "Português (Brasil) (81%)", selected: false},
{code: "si", name_with_percent: "සිංහල (20%)", selected: false},
{code: "zh-hans", name_with_percent: "中文(简体) (86%)", selected: false},
];
const formatted_list = get_language_list_columns("en");
for (const element of _.range(0, formatted_list.length)) {
assert.equal(formatted_list[element].name, successful_formatted_list[element].name);
assert.equal(formatted_list[element].code, successful_formatted_list[element].code);
assert.equal(
formatted_list[element].name_with_percent,
successful_formatted_list[element].name_with_percent,
);
assert.equal(formatted_list[element].selected, successful_formatted_list[element].selected);
}
});

View File

@@ -0,0 +1,740 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
set_global("document", {});
class ClipboardEvent {}
set_global("ClipboardEvent", ClipboardEvent);
mock_esm("../src/ui_util", {
place_caret_at_end: noop,
});
set_global("getSelection", () => ({
anchorOffset: 0,
}));
const input_pill = zrequire("input_pill");
function pill_html(value) {
const opts = {
display_value: value,
};
return require("../templates/input_pill.hbs")(opts);
}
run_test("basics", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(data.display_value, "JavaScript");
return html;
});
const $pill_input = $.create("pill_input");
const $container = $.create("container");
$container.set_find_results(".input", $pill_input);
const widget = input_pill.create({
$container,
create_item_from_text: noop,
get_text_from_item: noop,
get_display_value_from_item: (item) => item.language,
});
// type for a pill can be any string but it needs to be
// defined while creating any pill.
const item = {
language: "JavaScript",
type: "language",
};
let inserted_before;
const expected_html = pill_html("JavaScript");
$pill_input[0].before = (element) => {
inserted_before = true;
assert.equal(element.innerHTML, expected_html);
};
widget.appendValidatedData(item);
assert.ok(!widget.is_pending());
assert.ok(inserted_before);
assert.deepEqual(widget.items(), [item]);
});
function set_up() {
const items = {
blue: {
color_name: "BLUE",
description: "color of the sky",
type: "color",
},
red: {
color_name: "RED",
type: "color",
description: "color of stop signs",
},
yellow: {
color_name: "YELLOW",
type: "color",
description: "color of bananas",
},
};
const $pill_input = $.create("pill_input");
$pill_input[0].before = noop;
const create_item_from_text = (text) => items[text];
const $container = $.create("container");
$container.set_find_results(".input", $pill_input);
const config = {
$container,
create_item_from_text,
get_text_from_item: (item) => item.color_name,
get_display_value_from_item: (item) => item.color_name,
};
return {
config,
$pill_input,
items,
$container,
};
}
run_test("copy from pill", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.ok(["BLUE", "RED"].includes(data.display_value));
return html;
});
const info = set_up();
const config = info.config;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
const copy_handler = $container.get_on_handler("copy", ".pill");
let copied_text;
const pill_stub = $(pill_html("RED"))[0];
const originalEvent = new ClipboardEvent();
originalEvent.clipboardData = {
setData(format, text) {
assert.equal(format, "text/plain");
copied_text = text;
},
};
const e = {
originalEvent,
preventDefault: noop,
};
copy_handler.call(pill_stub, e);
assert.equal(copied_text, "RED");
});
run_test("paste to input", ({mock_template}) => {
mock_template("input_pill.hbs", true, (_data, html) => html);
const info = set_up();
const config = info.config;
const $container = info.$container;
const items = info.items;
const widget = input_pill.create(config);
const paste_handler = $container.get_on_handler("paste", ".input");
const paste_text = "blue,yellow";
const originalEvent = new ClipboardEvent();
originalEvent.clipboardData = {
getData(format) {
assert.equal(format, "text/plain");
return paste_text;
},
};
const e = {
originalEvent,
preventDefault: noop,
};
document.execCommand = (cmd, _, text) => {
assert.equal(cmd, "insertText");
$container.find(".input").text(text);
};
paste_handler(e);
assert.deepEqual(widget.items(), [items.blue, items.yellow]);
let entered = false;
widget.createPillonPaste(() => {
entered = true;
});
paste_handler(e);
assert.ok(entered);
});
run_test("arrows on pills", ({mock_template}) => {
mock_template("input_pill.hbs", true, (_data, html) => html);
const info = set_up();
const config = info.config;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
const key_handler = $container.get_on_handler("keydown", ".pill");
function test_key(c) {
key_handler({
key: c,
});
}
const $prev_pill_stub = $.create("prev-pill-stub");
const $next_pill_stub = $.create("next-pill-stub");
const $pill_stub = $.create("pill-stub");
$pill_stub.set_prev($prev_pill_stub);
$pill_stub.set_next($next_pill_stub);
$container.set_find_results(".pill:focus", $pill_stub);
// We use the same stub to test both arrows, since we don't
// actually cause any real state changes here. We stub out
// the only interaction, which is to move the focus.
test_key("ArrowLeft");
assert.ok($prev_pill_stub.is(":focus"));
test_key("ArrowRight");
assert.ok($next_pill_stub.is(":focus"));
});
run_test("left arrow on input", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
return html;
});
const info = set_up();
const config = info.config;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
const key_handler = $container.get_on_handler("keydown", ".input");
const $pill_stub = $.create("pill-stub");
$container.set_find_results(".pill", $pill_stub);
key_handler({
key: "ArrowLeft",
});
assert.ok($pill_stub.is(":focus"));
});
run_test("comma", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
return html;
});
const info = set_up();
const config = info.config;
const items = info.items;
const $pill_input = info.$pill_input;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
assert.deepEqual(widget.items(), [items.blue, items.red]);
const key_handler = $container.get_on_handler("keydown", ".input");
$pill_input.text(" yel");
key_handler({
key: ",",
preventDefault: noop,
});
assert.deepEqual(widget.items(), [items.blue, items.red]);
$pill_input.text(" yellow");
key_handler({
key: ",",
preventDefault: noop,
});
assert.deepEqual(widget.items(), [items.blue, items.red, items.yellow]);
});
run_test("Enter key with text", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
return html;
});
const info = set_up();
const config = info.config;
const items = info.items;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
assert.deepEqual(widget.items(), [items.blue, items.red]);
const key_handler = $container.get_on_handler("keydown", ".input");
key_handler.call(
{
textContent: " yellow ",
},
{
key: "Enter",
preventDefault: noop,
stopPropagation: noop,
},
);
assert.deepEqual(widget.items(), [items.blue, items.red, items.yellow]);
});
run_test("insert_remove", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
assert.ok(html.startsWith, "<div class='pill'");
return html;
});
const info = set_up();
const config = info.config;
const $pill_input = info.$pill_input;
const items = info.items;
const $container = info.$container;
const inserted_html = [];
$pill_input[0].before = (element) => {
inserted_html.push(element.innerHTML);
};
const widget = input_pill.create(config);
let created;
let removed;
widget.onPillCreate(() => {
created = true;
});
widget.onPillRemove(() => {
removed = true;
});
widget.appendValue("blue,chartreuse,red,yellow,mauve");
assert.ok(created);
assert.ok(!removed);
assert.deepEqual(inserted_html, [pill_html("BLUE"), pill_html("RED"), pill_html("YELLOW")]);
assert.deepEqual(widget.items(), [items.blue, items.red, items.yellow]);
assert.equal($pill_input.text(), "chartreuse, mauve");
assert.equal(widget.is_pending(), true);
widget.clear_text();
assert.equal($pill_input.text(), "");
assert.equal(widget.is_pending(), false);
let color_removed;
function set_colored_removed_func(color) {
return () => {
color_removed = color;
};
}
let color_focused;
function handle_event(color) {
return () => {
color_focused = color;
};
}
const pills = widget._get_pills_for_testing();
for (const pill of pills) {
pill.$element[0].remove = set_colored_removed_func(pill.item.color_name);
pill.$element.on("focus", handle_event(pill.item.color_name));
}
let key_handler = $container.get_on_handler("keydown", ".input");
key_handler.call(
{
textContent: "",
},
{
key: "Backspace",
preventDefault: noop,
},
);
// The first backspace focuses the pill, the second removes it.
assert.ok(!removed);
assert.equal(color_focused, "YELLOW");
const yellow_pill = pills.find((pill) => pill.item.color_name === "YELLOW");
$container.set_find_results(".pill:focus", yellow_pill.$element);
const $prev_pill_stub = $("<prev-stub>");
yellow_pill.$element.set_prev($prev_pill_stub);
yellow_pill.$element.set_next($("<next-stub>"));
key_handler = $container.get_on_handler("keydown", ".pill");
key_handler.call(
{},
{
key: "Backspace",
preventDefault: noop,
},
);
assert.ok(removed);
assert.equal(color_removed, "YELLOW");
assert.ok($prev_pill_stub.is(":focus"));
color_removed = undefined;
$prev_pill_stub.trigger("blur");
assert.deepEqual(widget.items(), [items.blue, items.red]);
const $focus_pill_stub = $(pill_html("RED"));
$focus_pill_stub.set_prev($prev_pill_stub);
$focus_pill_stub.set_next($("<next-stub>"));
$container.set_find_results(".pill:focus", $focus_pill_stub);
const red_pill = pills.find((pill) => pill.item.color_name === "RED");
// Disabled pill should not be removed.
red_pill.disabled = true;
key_handler = $container.get_on_handler("keydown", ".pill");
key_handler({
key: "Backspace",
preventDefault: noop,
});
assert.equal(color_removed, undefined);
assert.ok($prev_pill_stub.is(":focus"));
// We should be able to remove the pill after marking it as not
// disabled.
red_pill.disabled = false;
$prev_pill_stub.trigger("blur");
assert.deepEqual(widget.items(), [items.blue, items.red]);
key_handler({
key: "Backspace",
preventDefault: noop,
});
assert.equal(color_removed, "RED");
assert.ok($prev_pill_stub.is(":focus"));
});
run_test("exit button on pill", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
assert.ok(html.startsWith, "<div class='pill'");
return html;
});
const info = set_up();
const config = info.config;
const items = info.items;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
const pills = widget._get_pills_for_testing();
for (const pill of pills) {
pill.$element[0].remove = noop;
}
const $curr_pill_stub = {
[0]: $(pill_html("BLUE"))[0],
length: 1,
};
const exit_button_stub = {
to_$: () => ({
closest(sel) {
assert.equal(sel, ".pill");
return $curr_pill_stub;
},
}),
};
const e = {
stopPropagation: noop,
};
const exit_click_handler = $container.get_on_handler("click", ".exit");
exit_click_handler.call(exit_button_stub, e);
assert.deepEqual(widget.items(), [items.red]);
});
run_test("misc things", () => {
const info = set_up();
const $container = info.$container;
const $pill_input = info.$pill_input;
input_pill.create(info.config);
// animation
const animation_end_handler = $container.get_on_handler("animationend", ".input");
let shake_class_removed = false;
const input_stub = {
to_$: () => ({
removeClass(cls) {
assert.equal(cls, "input-validation-shake");
shake_class_removed = true;
},
}),
};
animation_end_handler.call(input_stub);
assert.ok(shake_class_removed);
// click on container
const container_click_handler = $container.get_on_handler("click");
const $stub = $.create("the-pill-container");
$stub.set_find_results(".input", $pill_input);
$stub.set_matches(".pill-container", true);
const this_ = {
to_$: () => $stub,
};
container_click_handler.call(this_, {target: this_});
});
run_test("appendValue/clear", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
assert.ok(html.startsWith, "<div class='pill'");
return html;
});
const $pill_input = $.create("pill_input");
const $container = $.create("container");
$container.set_find_results(".input", $pill_input);
const config = {
$container,
create_item_from_text: (s) => ({type: "color", color_name: s}),
get_text_from_item: /* istanbul ignore next */ (s) => s.color_name,
get_display_value_from_item: (s) => s.color_name,
};
$pill_input[0].before = noop;
const widget = input_pill.create(config);
// First test some early-exit code.
widget.appendValue("");
assert.deepEqual(widget._get_pills_for_testing(), []);
// Now set up real data.
widget.appendValue("red,yellow,blue");
const pills = widget._get_pills_for_testing();
const removed_colors = [];
for (const pill of pills) {
pill.$element[0].remove = () => {
removed_colors.push(pill.item.color_name);
};
}
widget.clear();
// Note that we remove colors in the reverse order that we inserted.
assert.deepEqual(removed_colors, ["blue", "yellow", "red"]);
assert.equal($pill_input[0].textContent, "");
});
run_test("getCurrentText", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
return html;
});
const info = set_up();
const config = info.config;
const items = info.items;
const $pill_input = info.$pill_input;
const $container = info.$container;
const widget = input_pill.create(config);
widget.appendValue("blue,red");
assert.deepEqual(widget.items(), [items.blue, items.red]);
$pill_input.text("yellow");
assert.equal(widget.getCurrentText(), "yellow");
const key_handler = $container.get_on_handler("keydown", ".input");
key_handler({
key: " ",
preventDefault: noop,
});
key_handler({
key: ",",
preventDefault: noop,
});
assert.deepEqual(widget.items(), [items.blue, items.red, items.yellow]);
});
run_test("onTextInputHook", () => {
const info = set_up();
const config = info.config;
const widget = input_pill.create(config);
const $container = info.$container;
const $pill_input = info.$pill_input;
let hookCalled = false;
let currentText = "re";
const onTextInputHook = () => {
hookCalled = true;
// Test that the hook always gets the correct updated text.
assert.equal(widget.getCurrentText(), currentText);
};
widget.onTextInputHook(onTextInputHook);
const input_handler = $container.get_on_handler("input", ".input");
$pill_input.text(currentText);
input_handler();
currentText += "d";
$pill_input.text(currentText);
input_handler();
assert.ok(hookCalled);
});
run_test("getPillByPredicate", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
return html;
});
const info = set_up();
const widget = input_pill.create(info.config);
widget.appendValue("blue,red,yellow");
// Test finding pill by color name
const found_blue_pill = widget.getPillByPredicate((item) => item.color_name === "BLUE");
assert.ok(found_blue_pill);
assert.equal(found_blue_pill.item.color_name, "BLUE");
assert.equal(found_blue_pill.item.description, "color of the sky");
const found_red_pill = widget.getPillByPredicate((item) => item.color_name === "RED");
assert.ok(found_red_pill);
assert.equal(found_red_pill.item.color_name, "RED");
assert.equal(found_red_pill.item.description, "color of stop signs");
// Test with non-existent item returns undefined
const nonexistent_pill = widget.getPillByPredicate((item) => item.color_name === "GREEN");
assert.equal(nonexistent_pill, undefined);
// Test finding pill by different property (description)
const found_by_description = widget.getPillByPredicate(
(item) => item.description === "color of the sky",
);
assert.ok(found_by_description);
assert.equal(found_by_description.item.color_name, "BLUE");
});
run_test("updatePill", ({mock_template}) => {
mock_template("input_pill.hbs", true, (data, html) => {
assert.equal(typeof data.display_value, "string");
return html;
});
const info = set_up();
const widget = input_pill.create(info.config);
widget.appendValue("blue,red");
const pills = widget._get_pills_for_testing();
assert.equal(pills.length, 2);
const blue_pill = pills[0];
assert.equal(blue_pill.item.color_name, "BLUE");
assert.equal(blue_pill.item.description, "color of the sky");
// Update the blue pill with new data
const updated_blue_data = {
color_name: "DARK BLUE",
description: "color of the deep ocean",
type: "color",
};
let element_replaced = false;
blue_pill.$element[0].replaceWith = (new_element) => {
element_replaced = true;
assert.equal(new_element.innerHTML, pill_html("DARK BLUE"));
};
widget.updatePill(blue_pill.$element[0], updated_blue_data);
// Verify the pill element was replaced with new HTML
assert.ok(element_replaced);
// Verify that pill.$element was updated by updatePill
assert.equal(blue_pill.$element.html(), pill_html("DARK BLUE"));
// Verify the pill's internal data was updated
assert.equal(blue_pill.item.color_name, "DARK BLUE");
assert.equal(blue_pill.item.description, "color of the deep ocean");
// Verify other pill is unchanged
const red_pill = pills[1];
assert.equal(red_pill.item.color_name, "RED");
assert.equal(red_pill.item.description, "color of stop signs");
// Test updating non-existent pill element - should be a no-op
const fake_element = {};
widget.updatePill(fake_element, updated_blue_data);
// Blue pill data should remain unchanged
assert.equal(blue_pill.item.color_name, "DARK BLUE");
assert.equal(blue_pill.item.description, "color of the deep ocean");
});

View File

@@ -0,0 +1,113 @@
"use strict";
const assert = require("node:assert/strict");
const url_encoding_test_cases = require("../../zerver/tests/fixtures/url_encoding_test_cases.json");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const internal_url = zrequire("internal_url");
run_test("test encodeHashComponent", () => {
for (const test of url_encoding_test_cases) {
assert.equal(internal_url.encodeHashComponent(test.input), test.expected_output);
}
});
run_test("test decodeHashComponent", () => {
const decoded = "https://www.zulipexample.com";
const encoded = "https.3A.2F.2Fwww.2Ezulipexample.2Ecom";
const result = internal_url.decodeHashComponent(encoded);
assert.equal(result, decoded);
});
run_test("test stream_id_to_slug", () => {
const maybe_get_stream_name = () => "onetwo three";
const result = internal_url.stream_id_to_slug(123, maybe_get_stream_name);
assert.equal(result, "123-onetwo-three");
});
run_test("test stream_id_to_slug failed lookup", () => {
const maybe_get_stream_name = () => undefined;
const result = internal_url.stream_id_to_slug(123, maybe_get_stream_name);
assert.equal(result, "123-unknown");
});
run_test("test encode_stream_id", () => {
const maybe_get_stream_name = () => "stream (with brackets)";
const result = internal_url.encode_stream_id(123, maybe_get_stream_name);
assert.equal(result, "123-stream-.28with-brackets.29");
});
run_test("test by_stream_url", () => {
const maybe_get_stream_name = () => "a test stream";
const result = internal_url.by_stream_url(123, maybe_get_stream_name);
assert.equal(result, "#narrow/channel/123-a-test-stream");
});
run_test("test by_channel_topic_list_url", () => {
const maybe_get_stream_name = () => "a test stream";
const result = internal_url.by_channel_topic_list_url(123, maybe_get_stream_name);
assert.equal(result, "#topics/channel/123-a-test-stream");
});
run_test("test by_stream_topic_url", () => {
const maybe_get_stream_name = () => "a test stream";
// Test stream_topic_url is a traditional topic link when the
// message_id to be encoded is undefined.
let result = internal_url.by_stream_topic_url(123, "test topic", maybe_get_stream_name);
assert.equal(result, "#narrow/channel/123-a-test-stream/topic/test.20topic");
// Test stream_topic_url is a topic permaling when the
// message_id to be encoded is not undefined.
result = internal_url.by_stream_topic_url(123, "test topic", maybe_get_stream_name, 12);
assert.equal(result, "#narrow/channel/123-a-test-stream/topic/test.20topic/with/12");
});
run_test("test encode_slug", () => {
// The test cases include stream id to mirror how
// internal_url.encode_stream_id works.
const test_cases = [
// diff=0, ratio=1.0 → included (pure ASCII)
["1-test-stream-autocomplete", true],
// 1-books.2C-film.2C-tv.2C-and-games;
// diff=6, ratio=1.214 → included (ASCII with commas)
["1-books,-film,-tv,-and-games", true],
// 1-.E7.B2.BE.E9.80.89.20-.20.E8.AF.91.E6.96.87;
// diff=36, ratio=5.0 → excluded (Chinese with spaces)
["1-精选 - 译文", false],
// 1-go.C3.BBta.20.C3.A0.20l.27.C5.93uf.20br.C3.BBl.C3.A9;
// diff=33, ratio=2.571 → excluded (many accented Latin chars)
["1-goûta à l'œuf brûlé", false],
// 1-.E0.B0.AF.E0.B0.B6;
// diff=16, ratio=5.0 → excluded (very short non-Latin slug)
["1-యశ", false],
// 1-.282.2B3.29.255;
// diff=8, ratio=1.889 → excluded (ASCII special chars, short slug)
["1-(2+3)%5", false],
// 1-abcdefghijklmnop-.C3.A9.C3.A9;
// diff=10, ratio=1.476 → included (at diff limit)
["1-abcdefghijklmnop-éé", true],
// 1-abcdefghijklmno-.C3.A9.C3.A9;
// diff=10, ratio=1.5 exactly → included (at ratio limit)
["1-abcdefghijklmno-éé", true],
// 1-abcdefghijklmnopqrstu-.C3.A9.C3.A9.2E;
// diff=12, ratio=1.444 → excluded (over diff limit)
["1-abcdefghijklmnopqrstu-éé.", false],
// 1-abcd-.C3.A9;
// diff=5, ratio=1.625 → excluded (over ratio limit)
["1-abcd-é", false],
];
for (const [channel_slug, should_encode_slug] of test_cases) {
if (should_encode_slug) {
assert.equal(
internal_url.encode_slug(1, channel_slug),
internal_url.encodeHashComponent(channel_slug),
);
} else {
assert.ok(Number(internal_url.encode_slug(1, channel_slug)) === 1);
}
}
});

View File

@@ -0,0 +1,79 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
const keydown_util = zrequire("keydown_util");
run_test("test_early_returns", () => {
const $stub = $.create("stub");
const opts = {
$elem: $stub,
handlers: {
/* istanbul ignore next */
ArrowLeft() {
throw new Error("do not dispatch this with alt key");
},
},
};
keydown_util.handle(opts);
const e1 = {
type: "keydown",
key: "a", // not in keys
};
$stub.trigger(e1);
const e2 = {
type: "keydown",
key: "Enter", // no handler
};
$stub.trigger(e2);
const e3 = {
type: "keydown",
key: "ArrowLeft",
altKey: true, // let browser handle
};
$stub.trigger(e3);
});
run_test("test_ime_enter_events", () => {
// these events shouldn't be recognized as a return keypress.
const event_1 = {
key: "Enter",
originalEvent: {
isComposing: true,
},
};
const event_2 = {
key: "Random",
originalEvent: {
isComposing: false,
},
};
assert.ok(!keydown_util.is_enter_event(event_1));
assert.ok(!keydown_util.is_enter_event(event_2));
// these are valid return keypress events.
const event_3 = {
key: "Enter",
originalEvent: {
isComposing: false,
},
};
const event_4 = {
key: "Enter",
// Edgacase: if there is no originalEvent, JQuery didn't provide the object.
};
assert.ok(keydown_util.is_enter_event(event_3));
assert.ok(keydown_util.is_enter_event(event_4));
});

View File

@@ -0,0 +1,39 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {LazySet} = zrequire("lazy_set");
/*
We mostly test LazySet indirectly. This code
may be short-lived, anyway, once we change
how we download subscribers in page_params.
*/
run_test("map", () => {
const ls = new LazySet([1, 2]);
assert.deepEqual(
ls.map((n) => n * 3),
[3, 6],
);
});
run_test("size", () => {
const ls = new LazySet([1, 2]);
assert.deepEqual(ls.size, 2);
ls._make_set();
assert.deepEqual(ls.size, 2);
});
run_test("conversions", () => {
blueslip.expect("error", "not a number", 2);
const ls = new LazySet([1, 2]);
ls.add("3");
assert.ok(ls.has("3"));
});

View File

@@ -0,0 +1,159 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, set_global, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const {$} = require("./lib/zjquery.cjs");
mock_esm("../src/resize", {
resize_stream_filters_container() {},
});
const {Filter} = zrequire("../src/filter");
const left_sidebar_navigation_area = zrequire("left_sidebar_navigation_area");
const scheduled_messages = zrequire("scheduled_messages");
const message_reminder = zrequire("message_reminder");
run_test("narrowing", ({override_rewire}) => {
override_rewire(
left_sidebar_navigation_area,
"select_top_left_corner_item",
(narrow_to_activate) => {
const targets = [
".top_left_mentions",
".top_left_starred_messages",
".top_left_all_messages",
".top_left_recent_view",
".top_left_inbox",
];
for (const target of targets) {
$(target).removeClass("top-left-active-filter");
}
if (narrow_to_activate !== "") {
$(narrow_to_activate).addClass("top-left-active-filter");
}
},
);
let filter = new Filter([{operator: "is", operand: "mentioned"}]);
// activating narrow
left_sidebar_navigation_area.handle_narrow_activated(filter);
assert.ok($(".top_left_mentions").hasClass("top-left-active-filter"));
filter = new Filter([{operator: "is", operand: "starred"}]);
left_sidebar_navigation_area.handle_narrow_activated(filter);
assert.ok($(".top_left_starred_messages").hasClass("top-left-active-filter"));
filter = new Filter([{operator: "in", operand: "home"}]);
left_sidebar_navigation_area.handle_narrow_activated(filter);
assert.ok($(".top_left_all_messages").hasClass("top-left-active-filter"));
// deactivating narrow
left_sidebar_navigation_area.handle_narrow_activated(new Filter([]));
assert.ok(!$(".top_left_all_messages").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_mentions").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_starred_messages").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_recent_view").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_inbox").hasClass("top-left-active-filter"));
set_global("setTimeout", (f) => {
f();
});
left_sidebar_navigation_area.highlight_recent_view();
assert.ok(!$(".top_left_all_messages").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_mentions").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_starred_messages").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_inbox").hasClass("top-left-active-filter"));
assert.ok($(".top_left_recent_view").hasClass("top-left-active-filter"));
left_sidebar_navigation_area.handle_narrow_activated(new Filter([]));
left_sidebar_navigation_area.highlight_inbox_view();
assert.ok(!$(".top_left_all_messages").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_mentions").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_starred_messages").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_recent_view").hasClass("top-left-active-filter"));
assert.ok($(".top_left_inbox").hasClass("top-left-active-filter"));
left_sidebar_navigation_area.highlight_all_messages_view();
assert.ok(!$(".top_left_mentions").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_starred_messages").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_recent_view").hasClass("top-left-active-filter"));
assert.ok(!$(".top_left_inbox").hasClass("top-left-active-filter"));
assert.ok($(".top_left_all_messages").hasClass("top-left-active-filter"));
});
run_test("update_count_in_dom", () => {
function make_elem($elem, count_selector) {
const $count = $(count_selector);
$elem.set_find_results(".unread_count", $count);
$count.set_parent($elem);
return $elem;
}
const counts = {
mentioned_message_count: 222,
home_unread_messages: 333,
stream_unread_messages: 666,
stream_count: new Map(),
};
message_reminder.set_reminders_by_id_for_testing(new Map([[1, {id: 1}]]));
scheduled_messages.set_scheduled_messages_by_id_for_testing(
new Map([
[1, {id: 1}],
[2, {id: 2}],
]),
);
$(".selected-home-view").set_find_results(".sidebar-menu-icon", $("<menu-icon>"));
make_elem($(".top_left_mentions"), "<mentioned-count>");
make_elem($(".top_left_inbox"), "<home-count>");
make_elem($(".selected-home-view"), "<home-count>");
make_elem($(".top_left_condensed_unread_marker"), "<condensed-unread-count>");
make_elem($(".top_left_starred_messages"), "<starred-count>");
make_elem($(".top_left_scheduled_messages"), "<scheduled-count>");
make_elem($(".top_left_reminders"), "<reminders-count>");
left_sidebar_navigation_area.update_dom_with_unread_counts(counts, false);
left_sidebar_navigation_area.update_starred_count(444, false);
left_sidebar_navigation_area.update_scheduled_messages_row();
left_sidebar_navigation_area.update_reminders_row();
assert.equal($("<mentioned-count>").text(), "222");
assert.equal($("<home-count>").text(), "333");
assert.equal($("<condensed-unread-count>").text(), "333");
assert.equal($("<starred-count>").text(), "444");
assert.equal($("<scheduled-count>").text(), "2");
assert.equal($("<reminders-count>").text(), "1");
assert.ok(!$(".top_left_scheduled_messages").hasClass("hidden-by-filters"));
assert.ok(!$(".top_left_reminders").hasClass("hidden-by-filters"));
counts.mentioned_message_count = 0;
message_reminder.set_reminders_by_id_for_testing(new Map());
scheduled_messages.set_scheduled_messages_by_id_for_testing(new Map());
left_sidebar_navigation_area.update_dom_with_unread_counts(counts, false);
// Starred count is hidden.
left_sidebar_navigation_area.update_starred_count(444, true);
left_sidebar_navigation_area.update_scheduled_messages_row();
left_sidebar_navigation_area.update_reminders_row();
assert.ok(!$("<mentioned-count>").visible());
assert.equal($("<mentioned-count>").text(), "");
assert.equal($("<starred-count>").text(), "444");
assert.ok($(".top_left_starred_messages").hasClass("hide_starred_message_count"));
assert.ok($(".top_left_scheduled_messages").hasClass("hidden-by-filters"));
assert.ok($(".top_left_reminders").hasClass("hidden-by-filters"));
});

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();

275
web/tests/lightbox.test.cjs Normal file
View File

@@ -0,0 +1,275 @@
"use strict";
const assert = require("node:assert/strict");
const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {$} = require("./lib/zjquery.cjs");
const message_store = mock_esm("../src/message_store");
const rows = mock_esm("../src/rows");
const people = mock_esm("../src/people");
const lightbox = zrequire("lightbox");
// Counter to generate unique zjquery selector names.
let test_counter = 0;
function make_media_element($mock_jq, src) {
// parse_media_data expects an HTMLImageElement, but for testing
// we use a plain object with `src` (read by canonical_url_of_media)
// and `to_$` (used by zjquery to wrap in FakeJQuery).
return {
src,
to_$() {
return $mock_jq;
},
};
}
function make_image({img_src, parent_href, parent_aria_label, img_attrs = {}}) {
test_counter += 1;
const n = test_counter;
const $img = $.create(`<img-${n}>`);
const $parent = $.create(`<parent-${n}>`);
$img.attr("src", img_src);
for (const [attr, value] of Object.entries(img_attrs)) {
$img.attr(attr, value);
}
if (parent_href !== undefined) {
$parent.attr("href", parent_href);
}
if (parent_aria_label !== undefined) {
$parent.attr("aria-label", parent_aria_label);
}
$img.set_parent($parent);
$parent.set_matches("*", true);
// Not inside any special container by default.
for (const sel of [
".youtube-video",
".vimeo-video",
".embed-video",
".message_inline_video",
".preview_content",
]) {
$img.set_closest_results(sel, []);
}
// Default to overlay context.
const $row = $.create(`<row-${n}>`);
rows.get_closest_row = () => $row;
rows.is_overlay_row = () => true;
people.my_full_name = () => "Test User";
return {$img, $parent, media: make_media_element($img, img_src)};
}
run_test("parse_media_data server-rendered image uses parent href", () => {
const {media} = make_image({
img_src: "https://example.com/thumbnail.jpg",
parent_href: "https://example.com/original.jpg",
parent_aria_label: "Image title",
});
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "image");
assert.equal(result.url, "https://example.com/original.jpg");
assert.equal(result.source, "https://example.com/original.jpg");
assert.equal(result.preview, "https://example.com/thumbnail.jpg");
assert.equal(result.title, "Image title");
assert.equal(result.user, "Test User");
});
run_test("parse_media_data bare image without parent link", () => {
// Client-rendered images (e.g., in drafts) are bare <img> tags
// not wrapped in an <a href>, so url falls back to img src.
const {media} = make_image({
img_src: "https://example.com/image.png",
// No parent_href — simulates a bare <img>.
});
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "image");
assert.equal(result.url, "https://example.com/image.png");
assert.equal(result.source, "https://example.com/image.png");
assert.equal(result.preview, "https://example.com/image.png");
});
run_test("parse_media_data with data-src-fullsize", () => {
// Remote images proxied through camo have a separate full-size URL.
const camo_thumbnail = "https://example.com/camo/thumb?size=300x200";
const camo_fullsize = "https://example.com/camo/full?size=0x0";
const original_url = "https://remote.example.com/photo.jpg";
const {media} = make_image({
img_src: camo_thumbnail,
parent_href: original_url,
img_attrs: {"data-src-fullsize": camo_fullsize},
});
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "image");
// source uses the full-size camo URL for lightbox display.
assert.equal(result.source, camo_fullsize);
// preview uses the camo thumbnail for the carousel.
assert.equal(result.preview, camo_thumbnail);
// url uses the original for the download link.
assert.equal(result.url, original_url);
});
run_test("parse_media_data with data-transcoded-image", () => {
const {media} = make_image({
img_src: "https://example.com/uploads/original.avif",
parent_href: "https://example.com/uploads/original.avif",
img_attrs: {"data-transcoded-image": "transcoded.webp"},
});
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "image");
// source replaces the filename with the transcoded version.
assert.equal(result.source, "https://example.com/uploads/transcoded.webp");
assert.equal(result.preview, "https://example.com/uploads/original.avif");
});
run_test("parse_media_data with original dimensions", () => {
const {media} = make_image({
img_src: "https://example.com/image.png",
parent_href: "https://example.com/image.png",
img_attrs: {"data-original-dimensions": "1920x1080"},
});
const result = lightbox.parse_media_data(media);
assert.equal(result.original_width_px, 1920);
assert.equal(result.original_height_px, 1080);
});
run_test("parse_media_data with loading placeholder", () => {
const {$img, media} = make_image({
img_src: "https://example.com/image.png",
parent_href: "https://example.com/image.png",
});
$img[0].classList.add("image-loading-placeholder");
const result = lightbox.parse_media_data(media);
// Loading placeholders clear the preview.
assert.equal(result.preview, "");
assert.equal(result.source, "https://example.com/image.png");
});
run_test("parse_media_data inline video", () => {
const {$img, media} = make_image({
img_src: "https://example.com/camo/video-thumb",
parent_href: "https://example.com/video.mp4",
});
// Override to indicate this is an inline video.
$img.set_closest_results(".message_inline_video", $img);
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "inline-video");
// source uses the non-camo'd href for direct playback.
assert.equal(result.source, "https://example.com/video.mp4");
assert.equal(result.preview, "https://example.com/camo/video-thumb");
});
run_test("parse_media_data YouTube video", () => {
const {$img, $parent, media} = make_image({
img_src: "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
parent_href: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
});
$img.set_closest_results(".youtube-video", $img);
$parent.attr("data-id", "dQw4w9WgXcQ");
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "youtube-video");
assert.equal(result.source, "https://www.youtube.com/embed/dQw4w9WgXcQ");
});
run_test("parse_media_data YouTube video with start time", () => {
const {$img, $parent, media} = make_image({
img_src: "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
parent_href: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120",
});
$img.set_closest_results(".youtube-video", $img);
$parent.attr("data-id", "dQw4w9WgXcQ");
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "youtube-video");
assert.equal(result.source, "https://www.youtube.com/embed/dQw4w9WgXcQ?start=120");
});
run_test("parse_media_data Vimeo video", () => {
const {$img, $parent, media} = make_image({
img_src: "https://i.vimeocdn.com/video/123456_640.jpg",
parent_href: "https://vimeo.com/123456",
});
$img.set_closest_results(".vimeo-video", $img);
$parent.attr("data-id", "123456");
const result = lightbox.parse_media_data(media);
assert.equal(result.type, "vimeo-video");
assert.equal(result.source, "https://player.vimeo.com/video/123456");
});
run_test("parse_media_data message row context", () => {
const {media} = make_image({
img_src: "https://example.com/image.png",
parent_href: "https://example.com/image.png",
});
// Override context to message row instead of overlay.
rows.is_overlay_row = () => false;
rows.id = () => 42;
message_store.get = () => ({sender_full_name: "Alice"});
const result = lightbox.parse_media_data(media);
assert.equal(result.user, "Alice");
assert.equal(result.type, "image");
});
run_test("parse_media_data unknown message", () => {
const {media} = make_image({
img_src: "https://example.com/image2.png",
parent_href: "https://example.com/image2.png",
});
rows.is_overlay_row = () => false;
rows.id = () => 999;
message_store.get = () => undefined;
blueslip.expect("error", "Lightbox for unknown message");
const result = lightbox.parse_media_data(media);
assert.equal(result.user, undefined);
});
run_test("parse_media_data asset map cache", () => {
const img_src = "https://example.com/cached.png";
message_store.get = () => ({sender_full_name: "Bob"});
const {media: media1} = make_image({
img_src,
parent_href: img_src,
});
// make_image resets to overlay context; restore message row context.
rows.is_overlay_row = () => false;
rows.id = () => 100;
const result1 = lightbox.parse_media_data(media1);
assert.equal(result1.user, "Bob");
// Second call with same message_id and canonical URL returns cache.
const {media: media2} = make_image({
img_src,
parent_href: img_src,
});
rows.is_overlay_row = () => false;
rows.id = () => 100;
const result2 = lightbox.parse_media_data(media2);
assert.equal(result2, result1);
});

View File

@@ -0,0 +1,81 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const linkifiers = zrequire("linkifiers");
linkifiers.initialize([]);
function get_linkifier_regexes() {
return linkifiers.get_linkifier_map().keys().toArray();
}
run_test("compile_linkifier", () => {
// Verify basic pattern compilation with RE2JS.
linkifiers.update_linkifier_rules([
{
pattern: "TICKET-(?P<id>\\d+)",
url_template: "http://example1.example.com/{id}",
id: 10,
},
]);
let regexes = get_linkifier_regexes();
assert.equal(regexes.length, 1);
let matcher = regexes[0].matcher(" TICKET-42 ");
assert.ok(matcher.find());
assert.equal(matcher.group(2), "TICKET-42");
assert.equal(matcher.group("id"), "42");
// Test case with multiple named groups.
linkifiers.update_linkifier_rules([
{
pattern: "#cf(?P<contest>\\d+)(?P<problem>[A-Z][\\dA-Z]*)",
url_template: "http://example3.example.com",
id: 30,
},
]);
regexes = get_linkifier_regexes();
assert.equal(regexes.length, 1);
matcher = regexes[0].matcher(" #cf100Z ");
assert.ok(matcher.find());
assert.equal(matcher.group(2), "#cf100Z");
assert.equal(matcher.group("contest"), "100");
assert.equal(matcher.group("problem"), "Z");
// Boundary matching: linkifier only matches after boundary characters.
matcher = regexes[0].matcher("x#cf100Z ");
assert.ok(!matcher.find());
matcher = regexes[0].matcher("(#cf100Z)");
assert.ok(matcher.find());
assert.equal(matcher.group(2), "#cf100Z");
// Test incorrect syntaxes
// Just absolute garbage:
blueslip.expect("error", "Failed to compile linkifier!", 1);
linkifiers.update_linkifier_rules([
{
pattern: "!@#@(!#&((!&(@#(",
url_template: "http://example4.example.com",
id: 40,
},
]);
blueslip.reset();
assert.deepEqual(get_linkifier_regexes(), []);
// Python-only inline flags like (?L) are rejected by RE2JS.
blueslip.expect("error", "Failed to compile linkifier!", 1);
linkifiers.update_linkifier_rules([
{
pattern: "(?L)foo",
url_template: "http://example2.example.com",
id: 20,
},
]);
blueslip.reset();
assert.deepEqual(get_linkifier_regexes(), []);
});

View File

@@ -0,0 +1,161 @@
"use strict";
const assert = require("node:assert/strict");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test, noop} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs");
const {$} = require("./lib/zjquery.cjs");
const {ListCursor} = zrequire("list_cursor");
function basic_conf({first_key, prev_key, next_key}) {
const list = {
scroll_container_selector: "whatever",
find_li() {},
first_key,
prev_key,
next_key,
};
const conf = {
list,
highlight_class: "highlight",
};
return conf;
}
run_test("misc errors", ({override}) => {
const conf = basic_conf({
first_key: () => undefined,
prev_key: /* istanbul ignore next */ () => undefined,
next_key: /* istanbul ignore next */ () => undefined,
});
const cursor = new ListCursor(conf);
// Test that we just ignore empty
// lists for unknown keys.
override(conf.list, "find_li", ({key, force_render}) => {
assert.equal(key, "nada");
assert.equal(force_render, true);
return [];
});
cursor.get_row("nada");
blueslip.expect("error", "Caller is not checking keys for ListCursor.go_to");
cursor.go_to(undefined);
blueslip.expect("error", "Cannot highlight key for ListCursor");
cursor.go_to("nada");
cursor.prev();
cursor.next();
});
run_test("single item list", ({override}) => {
const valid_key = "42";
const conf = basic_conf({
first_key: /* istanbul ignore next */ () => valid_key,
next_key: () => undefined,
prev_key: () => undefined,
});
const cursor = new ListCursor(conf);
const $li_stub = {
length: 1,
addClass() {},
};
override(conf.list, "find_li", () => $li_stub);
override(cursor, "adjust_scroll", noop);
cursor.set_is_highlight_visible(true);
cursor.go_to(valid_key);
// Test prev/next, which should just silently do nothing.
cursor.prev();
cursor.next();
// The next line is also a noop designed to just give us test
// coverage.
cursor.go_to(valid_key);
});
run_test("multiple item list", ({override}) => {
const conf = basic_conf({
first_key: /* istanbul ignore next */ () => 1,
next_key: (key) => (key < 3 ? key + 1 : undefined),
prev_key: (key) => (key > 1 ? key - 1 : undefined),
});
const cursor = new ListCursor(conf);
override(cursor, "adjust_scroll", noop);
function li(key) {
return $.create(`item-${key}`);
}
const list_items = {
1: li(1),
2: li(2),
3: li(3),
};
override(conf.list, "find_li", ({key}) => list_items[key]);
cursor.go_to(2);
assert.equal(cursor.get_key(), 2);
assert.ok(!list_items[1].hasClass("highlight"));
// Selection is not highlighted until we want it to.
assert.ok(!list_items[2].hasClass("highlight"));
assert.ok(!list_items[3].hasClass("highlight"));
// Current selection is not highlighted until user wants
// to move the cursor.
// Also, cursor doesn't move if the selection was not
// previously highlighted.
cursor.next();
assert.equal(cursor.get_key(), 2);
assert.ok(!list_items[1].hasClass("highlight"));
assert.ok(list_items[2].hasClass("highlight"));
assert.ok(!list_items[3].hasClass("highlight"));
// Reset state
cursor.set_is_highlight_visible(false);
cursor.prev();
assert.equal(cursor.get_key(), 2);
assert.ok(!list_items[1].hasClass("highlight"));
assert.ok(list_items[2].hasClass("highlight"));
assert.ok(!list_items[3].hasClass("highlight"));
cursor.next();
cursor.next();
cursor.next();
assert.equal(cursor.get_key(), 3);
assert.ok(!list_items[1].hasClass("highlight"));
assert.ok(!list_items[2].hasClass("highlight"));
assert.ok(list_items[3].hasClass("highlight"));
cursor.prev();
cursor.prev();
cursor.prev();
assert.equal(cursor.get_key(), 1);
assert.ok(list_items[1].hasClass("highlight"));
assert.ok(!list_items[2].hasClass("highlight"));
assert.ok(!list_items[3].hasClass("highlight"));
override(conf.list, "find_li", () => list_items[1]);
cursor.redraw();
assert.ok(list_items[1].hasClass("highlight"));
assert.ok(!list_items[2].hasClass("highlight"));
assert.ok(!list_items[3].hasClass("highlight"));
cursor.clear();
assert.equal(cursor.get_key(), undefined);
cursor.redraw();
assert.ok(!list_items[1].hasClass("highlight"));
});

File diff suppressed because it is too large Load Diff

1078
web/tests/markdown.test.cjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,392 @@
"use strict";
const assert = require("node:assert/strict");
const {RE2JS} = require("re2js");
const Template = require("uri-template-lite");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const markdown = zrequire("markdown");
const linkifiers = zrequire("linkifiers");
const my_id = 101;
const user_map = new Map([
[my_id, "Me Myself"],
[105, "greg"],
]);
function get_actual_name_from_user_id(user_id) {
return user_map.get(user_id);
}
function get_user_id_from_name(name) {
for (const [user_id, _name] of user_map) {
if (name === _name) {
return user_id;
}
}
/* istanbul ignore next */
throw new Error(`unexpected name ${name}`);
}
function is_valid_full_name_and_user_id(name, user_id) {
return user_map.has(user_id) && user_map.get(user_id) === name;
}
function my_user_id() {
return my_id;
}
function is_valid_user_id(user_id) {
return user_map.has(user_id);
}
const staff_group = {
id: 201,
name: "Staff",
};
const user_group_map = new Map([[staff_group.name, staff_group]]);
function get_user_group_from_name(name) {
return user_group_map.get(name);
}
function is_member_of_user_group(user_group_id, user_id) {
assert.equal(user_group_id, staff_group.id);
assert.equal(user_id, my_id);
return true;
}
const social = {
stream_id: 301,
name: "social",
};
const sub_map = new Map([[social.name, social]]);
function get_stream_by_name(name) {
return sub_map.get(name);
}
function stream_hash(stream_id) {
return `stream-${stream_id}`;
}
function stream_topic_hash(stream_id, topic) {
return `stream-${stream_id}-topic-${topic}`;
}
function get_emoticon_translations() {
return [
{regex: /(:\))/g, replacement_text: ":smile:"},
{regex: /(<3)/g, replacement_text: ":heart:"},
];
}
const emoji_map = new Map([
["smile", "1f604"],
["alien", "1f47d"],
]);
function get_emoji_codepoint(emoji_name) {
return emoji_map.get(emoji_name);
}
function get_emoji_name(codepoint) {
for (const [emoji_name, _codepoint] of emoji_map) {
if (codepoint === _codepoint) {
return emoji_name;
}
}
/* istanbul ignore next */
throw new Error(`unexpected codepoint ${codepoint}`);
}
const realm_emoji_map = new Map([["heart", "/images/emoji/heart.bmp"]]);
function get_realm_emoji_url(emoji_name) {
return realm_emoji_map.get(emoji_name);
}
const regex = RE2JS.compile(
"(^|\\s|\\x{0085}|\\p{Z}|['\"(,:<])(#foo(?P<id>\\d+))($|[^\\p{L}\\p{N}])",
);
const linkifier_map = new Map([
[
regex,
{
url_template: new Template("http://foo.com/{id}"),
reverse_template: null,
alternative_url_templates: [],
},
],
]);
function get_linkifier_map() {
return linkifier_map;
}
const helper_config = {
// user stuff
get_actual_name_from_user_id,
get_user_id_from_name,
is_valid_full_name_and_user_id,
is_valid_user_id,
my_user_id,
// user groups
get_user_group_from_name,
is_member_of_user_group,
// stream hashes
get_stream_by_name,
stream_hash,
stream_topic_hash,
// settings
should_translate_emoticons: () => true,
// emojis
get_emoji_codepoint,
get_emoji_name,
get_emoticon_translations,
get_realm_emoji_url,
// linkifiers
get_linkifier_map,
};
function assert_parse(raw_content, expected_content) {
const {content} = markdown.parse({raw_content, helper_config});
assert.equal(content, expected_content);
}
run_test("basics", () => {
assert_parse("boring", "<p>boring</p>");
assert_parse("**bold**", "<p><strong>bold</strong></p>");
});
run_test("user mentions", () => {
assert_parse("@**greg**", '<p><span class="user-mention" data-user-id="105">@greg</span></p>');
assert_parse("@**|105**", '<p><span class="user-mention" data-user-id="105">@greg</span></p>');
assert_parse(
"@**greg|105**",
'<p><span class="user-mention" data-user-id="105">@greg</span></p>',
);
assert_parse(
"@**Me Myself|101**",
'<p><span class="user-mention" data-user-id="101">@Me Myself</span></p>',
);
});
run_test("user group mentions", () => {
assert_parse(
"@*Staff*",
'<p><span class="user-group-mention" data-user-group-id="201">@Staff</span></p>',
);
});
run_test("stream links", () => {
assert_parse(
"#**social**",
'<p><a class="stream" data-stream-id="301" href="/stream-301">#social</a></p>',
);
assert_parse(
"#**social>lunch**",
'<p><a class="stream-topic" data-stream-id="301" href="stream-301-topic-lunch">#social &gt; lunch</a></p>',
);
});
run_test("emojis", () => {
assert_parse(
"yup :)",
'<p>yup <span aria-label="smile" class="emoji emoji-1f604" role="img" title="smile">:smile:</span></p>',
);
assert_parse(
"I <3 JavaScript",
'<p>I <img alt=":heart:" class="emoji" src="/images/emoji/heart.bmp" title="heart"> JavaScript</p>',
);
assert_parse(
"Mars Attacks! \u{1F47D}",
'<p>Mars Attacks! <span aria-label="alien" class="emoji emoji-1f47d" role="img" title="alien">:alien:</span></p>',
);
});
run_test("linkifiers", () => {
assert_parse(
"see #foo12345 for details",
'<p>see <a href="http://foo.com/12345" title="http://foo.com/12345">#foo12345</a> for details</p>',
);
});
function assert_topic_links(topic, expected_links) {
const topic_links = markdown.get_topic_links(topic);
assert.deepEqual(topic_links, expected_links);
}
run_test("topic links", () => {
linkifiers.initialize([{pattern: "#foo(?P<id>\\d+)", url_template: "http://foo.com/{id}"}]);
markdown.initialize({
get_linkifier_map: linkifiers.get_linkifier_map,
});
assert_topic_links("progress on #foo101 and #foo102", [
{
text: "#foo101",
url: "http://foo.com/101",
},
{
text: "#foo102",
url: "http://foo.com/102",
},
]);
});
run_test("topic links repeated", () => {
// Links generated from repeated patterns should preserve the order.
const topic =
"#foo101 https://google.com #foo102 #foo103 https://google.com #foo101 #foo102 #foo103";
linkifiers.initialize([{pattern: "#foo(?P<id>\\d+)", url_template: "http://foo.com/{id}"}]);
assert_topic_links(topic, [
{
text: "#foo101",
url: "http://foo.com/101",
},
{
text: "https://google.com",
url: "https://google.com",
},
{
text: "#foo102",
url: "http://foo.com/102",
},
{
text: "#foo103",
url: "http://foo.com/103",
},
{
text: "https://google.com",
url: "https://google.com",
},
{
text: "#foo101",
url: "http://foo.com/101",
},
{
text: "#foo102",
url: "http://foo.com/102",
},
{
text: "#foo103",
url: "http://foo.com/103",
},
]);
});
run_test("topic links overlapping", () => {
// Using ":" instead of "#" in test patterns because ":" is in the
// boundary character set used by compile_linkifier, while "#" is not.
linkifiers.initialize([
{pattern: "[a-z]+(?P<id>1\\d+) :[a-z]+", url_template: "http://a.com/{id}"},
{pattern: "[a-z]+(?P<id>1\\d+)", url_template: "http://b.com/{id}"},
{pattern: ".+:(?P<id>[a-z]+)", url_template: "http://wildcard.com/{id}"},
{pattern: ":(?P<id>[a-z]+)", url_template: "http://c.com/{id}"},
]);
// b.com's pattern should be matched while it overlaps with c.com's.
assert_topic_links(":foo100", [
{
text: "foo100",
url: "http://b.com/100",
},
]);
// a.com's pattern should be matched while it overlaps with b.com's, wildcard.com's and c.com's.
assert_topic_links(":asd123 :asd", [
{
text: "asd123 :asd",
url: "http://a.com/123",
},
]);
// a.com's pattern do not match, wildcard.com's and b.com's patterns should match
// and the links are ordered by the matched index.
assert_topic_links("/:asd :foo100", [
{
text: "/:asd",
url: "http://wildcard.com/asd",
},
{
text: "foo100",
url: "http://b.com/100",
},
]);
assert_topic_links("foo.anything/:asd", [
{
text: "foo.anything/:asd",
url: "http://wildcard.com/asd",
},
]);
// While the raw URL "http://foo.com/:foo100" appears before b.com's match "foo100",
// we prioritize the linkifier match first.
assert_topic_links("http://foo.com/:foo100", [
{
text: "foo100",
url: "http://b.com/100",
},
]);
// Here the raw URL "https://foo.com/:asd" appears after wildcard.com's match "something https://foo.com/:asd".
// The latter is prioritized and the raw URL does not get included.
assert_topic_links("something https://foo.com/:asd", [
{
text: "something https://foo.com/:asd",
url: "http://wildcard.com/asd",
},
]);
});
run_test("topic links ordering by priority", () => {
// The same test case is also implemented in zerver/tests/test_markdown.py
linkifiers.initialize([
{pattern: "http", url_template: "http://example.com/"},
{pattern: "b#(?P<id>[a-z]+)", url_template: "http://example.com/b/{id}"},
{
pattern: "a#(?P<aid>[a-z]+) b#(?P<bid>[a-z]+)",
url_template: "http://example.com/a/{aid}/b/%(bid)",
},
{pattern: "a#(?P<id>[a-z]+)", url_template: "http://example.com/a/{id}"},
]);
// There should be 5 link matches in the topic, if ordered from the most prioritized to the least:
// 1. "http" (linkifier)
// 2. "b#bar" (linkifier)
// 3. "a#asd b#bar" (linkifier)
// 4. "a#asd" (linkifier)
// 5. "http://foo.com" (raw URL)
// When there are overlapping matches, the one that appears earlier in the list should
// have a topic link generated.
// For this test case, while "a#asd" and "a#asd b#bar" both match and they overlap,
// there is a match "b#bar" with a higher priority, preventing "a#asd b#bar" from being matched.
assert_topic_links("http://foo.com a#asd b#bar", [
{
text: "http",
url: "http://example.com/",
},
{
text: "a#asd",
url: "http://example.com/a/asd",
},
{
text: "b#bar",
url: "http://example.com/b/bar",
},
]);
});

Some files were not shown because too many files have changed in this diff Show More