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
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:
659
web/tests/activity.test.cjs
Normal file
659
web/tests/activity.test.cjs
Normal 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);
|
||||
});
|
||||
91
web/tests/activity_init.test.cjs
Normal file
91
web/tests/activity_init.test.cjs
Normal 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);
|
||||
});
|
||||
100
web/tests/activity_post.test.cjs
Normal file
100
web/tests/activity_post.test.cjs
Normal 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);
|
||||
});
|
||||
118
web/tests/activity_ui.test.cjs
Normal file
118
web/tests/activity_ui.test.cjs
Normal 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);
|
||||
});
|
||||
208
web/tests/alert_words.test.cjs
Normal file
208
web/tests/alert_words.test.cjs
Normal 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&C <3 >8 5'11" 5'11"</p>`,
|
||||
alerted: true,
|
||||
},
|
||||
`<p><span class='alert-word'>FD&C</span> <span class='alert-word'><3</span> <span class='alert-word'>>8</span> <span class='alert-word'>5'11"</span> <span class='alert-word'>5'11"</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"));
|
||||
});
|
||||
104
web/tests/alert_words_ui.test.cjs
Normal file
104
web/tests/alert_words_ui.test.cjs
Normal 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!`,
|
||||
);
|
||||
});
|
||||
22
web/tests/background_task.test.cjs
Normal file
22
web/tests/background_task.test.cjs
Normal 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);
|
||||
});
|
||||
62
web/tests/blueslip_stacktrace.test.cjs
Normal file
62
web/tests/blueslip_stacktrace.test.cjs
Normal 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
252
web/tests/bot_data.test.cjs
Normal 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]);
|
||||
});
|
||||
235
web/tests/box_resize.test.cjs
Normal file
235
web/tests/box_resize.test.cjs
Normal 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;
|
||||
}
|
||||
});
|
||||
77
web/tests/browser_history.test.cjs
Normal file
77
web/tests/browser_history.test.cjs
Normal 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");
|
||||
});
|
||||
35
web/tests/browser_support.test.cjs
Normal file
35
web/tests/browser_support.test.cjs
Normal 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);
|
||||
});
|
||||
729
web/tests/buddy_data.test.cjs
Normal file
729
web/tests/buddy_data.test.cjs
Normal 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);
|
||||
});
|
||||
376
web/tests/buddy_list.test.cjs
Normal file
376
web/tests/buddy_list.test.cjs
Normal 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
399
web/tests/channel.test.cjs
Normal 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
182
web/tests/channel_folders.test.cjs
Normal file
182
web/tests/channel_folders.test.cjs
Normal 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]);
|
||||
});
|
||||
128
web/tests/clipboard_handler.test.cjs
Normal file
128
web/tests/clipboard_handler.test.cjs
Normal 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);
|
||||
});
|
||||
36
web/tests/color_data.test.cjs
Normal file
36
web/tests/color_data.test.cjs
Normal 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
221
web/tests/common.test.cjs
Normal 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");
|
||||
});
|
||||
325
web/tests/components.test.cjs
Normal file
325
web/tests/components.test.cjs
Normal 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
883
web/tests/compose.test.cjs
Normal 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);
|
||||
});
|
||||
1096
web/tests/compose_actions.test.cjs
Normal file
1096
web/tests/compose_actions.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
506
web/tests/compose_call.test.cjs
Normal file
506
web/tests/compose_call.test.cjs
Normal 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);
|
||||
});
|
||||
82
web/tests/compose_call_session.test.cjs
Normal file
82
web/tests/compose_call_session.test.cjs
Normal 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");
|
||||
});
|
||||
});
|
||||
372
web/tests/compose_closed_ui.test.cjs
Normal file
372
web/tests/compose_closed_ui.test.cjs
Normal 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> > first_topic",
|
||||
"<rendered-channel-stub:first_stream> > second_topic",
|
||||
"<rendered-channel-stub:second_stream> > third_topic",
|
||||
"<rendered-channel-stub:second_stream> > 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> > <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> > 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");
|
||||
});
|
||||
143
web/tests/compose_fade.test.cjs
Normal file
143
web/tests/compose_fade.test.cjs
Normal 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());
|
||||
});
|
||||
844
web/tests/compose_paste.test.cjs
Normal file
844
web/tests/compose_paste.test.cjs
Normal 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`](#narrow/channel/4-Rome/topic/100.25.20profits.60)",
|
||||
],
|
||||
[
|
||||
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20*profits",
|
||||
"[#Rome > 100% *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 > $$ 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 > >100% profits](#narrow/channel/4-Rome/topic/.3E100.25.20profits)",
|
||||
],
|
||||
[
|
||||
"http://zulip.zulipdev.com/#narrow/stream/5-Romeo.60s-lair/topic/normal",
|
||||
"[#Romeo`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` @ 💬](#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;">"&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("&size=full"))";
|
||||
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, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 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, "Liberation Mono", 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, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 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: "Source Sans 3", "Helvetica Neue", 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, "Helvetica Neue", 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, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 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, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 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: "Source Sans 3", 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("http://localhost:9991/webpack/files/generated/emoji/google.webp"); background-size: 6100%; background-repeat: no-repeat; text-indent: 100%; white-space: nowrap; overflow: hidden; color: rgb(221, 222, 238); font-family: "Source Sans 3", 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: "Source Sans 3", 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("http://localhost:9991/webpack/files/generated/emoji/google.webp"); background-size: 6100%; background-repeat: no-repeat; text-indent: 100%; white-space: nowrap; overflow: hidden; color: rgb(221, 222, 238); font-family: "Source Sans 3", 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: "Source Sans 3", 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: "Lucida Grande", 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: "Linux Libertine", Georgia, Times, "Source Serif Pro", 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, "SF Mono", Menlo, Consolas, "Liberation Mono", 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;"><del>turtles are cool</del></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="{"1":3,"3":123}" data-sheets-userformat="{"2":769,"3":{"1":0},"11":3,"12":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="{ "1": 2, "2": "Kathleen"}">Kathleen</td><td align="left" data-sheets-value="{ "1": 2, "2": "Hanner"}">Hanner</td><td align="left" data-sheets-value="{ "1": 2, "2": "Female"}">Female</td><td align="left" data-sheets-value="{ "1": 2, "2": "United States"}">United States</td></tr><tr><td height="24" align="left" data-sheets-value="{ "1": 2, "2": "Nereida"}">Nereida</td><td align="left" data-sheets-value="{ "1": 2, "2": "Magwood"}">Magwood</td><td align="left" data-sheets-value="{ "1": 2, "2": "Female"}">Female</td><td align="left" data-sheets-value="{ "1": 2, "2": "United States"}">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 > 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 > 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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: "Source Sans 3 VF", 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");
|
||||
});
|
||||
300
web/tests/compose_pm_pill.test.cjs
Normal file
300
web/tests/compose_pm_pill.test.cjs
Normal 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);
|
||||
});
|
||||
439
web/tests/compose_reply.test.cjs
Normal file
439
web/tests/compose_reply.test.cjs
Normal 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,
|
||||
});
|
||||
});
|
||||
53
web/tests/compose_state.test.cjs
Normal file
53
web/tests/compose_state.test.cjs
Normal 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);
|
||||
});
|
||||
1594
web/tests/compose_ui.test.cjs
Normal file
1594
web/tests/compose_ui.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1011
web/tests/compose_validate.test.cjs
Normal file
1011
web/tests/compose_validate.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
3624
web/tests/composebox_typeahead.test.cjs
Normal file
3624
web/tests/composebox_typeahead.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
62
web/tests/conversation_participants.test.cjs
Normal file
62
web/tests/conversation_participants.test.cjs
Normal 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])));
|
||||
});
|
||||
224
web/tests/date_util.test.cjs
Normal file
224
web/tests/date_util.test.cjs
Normal 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);
|
||||
});
|
||||
28
web/tests/demo_organizations.test.cjs
Normal file
28
web/tests/demo_organizations.test.cjs
Normal 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);
|
||||
});
|
||||
19
web/tests/deprecated_feature_notice.test.cjs
Normal file
19
web/tests/deprecated_feature_notice.test.cjs
Normal 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);
|
||||
});
|
||||
88
web/tests/direct_message_group_data.test.cjs
Normal file
88
web/tests/direct_message_group_data.test.cjs
Normal 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
1829
web/tests/dispatch.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
356
web/tests/dispatch_subs.test.cjs
Normal file
356
web/tests/dispatch_subs.test.cjs
Normal 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
896
web/tests/drafts.test.cjs
Normal 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&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
803
web/tests/echo.test.cjs
Normal 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]);
|
||||
});
|
||||
63
web/tests/electron_bridge.test.cjs
Normal file
63
web/tests/electron_bridge.test.cjs
Normal 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
133
web/tests/emoji.test.cjs
Normal 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",
|
||||
},
|
||||
);
|
||||
});
|
||||
147
web/tests/emoji_picker.test.cjs
Normal file
147
web/tests/emoji_picker.test.cjs
Normal 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,
|
||||
);
|
||||
});
|
||||
77
web/tests/example1.test.cjs
Normal file
77
web/tests/example1.test.cjs
Normal 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
111
web/tests/example2.test.cjs
Normal 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);
|
||||
});
|
||||
93
web/tests/example3.test.cjs
Normal file
93
web/tests/example3.test.cjs
Normal 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
177
web/tests/example4.test.cjs
Normal 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
197
web/tests/example5.test.cjs
Normal 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");
|
||||
});
|
||||
69
web/tests/example6.test.cjs
Normal file
69
web/tests/example6.test.cjs
Normal 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
160
web/tests/example7.test.cjs
Normal 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
151
web/tests/example8.test.cjs
Normal 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…`));
|
||||
});
|
||||
176
web/tests/fenced_code.test.cjs
Normal file
176
web/tests/fenced_code.test.cjs
Normal 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 + "<script>alert("xss")</script>\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<b>"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('hello')"));
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
179
web/tests/fetch_status.test.cjs
Normal file
179
web/tests/fetch_status.test.cjs
Normal 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
3506
web/tests/filter.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
32
web/tests/focus_outline_util.test.cjs
Normal file
32
web/tests/focus_outline_util.test.cjs
Normal 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);
|
||||
});
|
||||
104
web/tests/fold_dict.test.cjs
Normal file
104
web/tests/fold_dict.test.cjs
Normal 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.",
|
||||
});
|
||||
});
|
||||
327
web/tests/folder_dropdown_widget.test.cjs
Normal file
327
web/tests/folder_dropdown_widget.test.cjs
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
71
web/tests/gear_menu_util.test.cjs
Normal file
71
web/tests/gear_menu_util.test.cjs
Normal 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);
|
||||
});
|
||||
390
web/tests/hash_util.test.cjs
Normal file
390
web/tests/hash_util.test.cjs
Normal 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);
|
||||
});
|
||||
474
web/tests/hashchange.test.cjs
Normal file
474
web/tests/hashchange.test.cjs
Normal 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
786
web/tests/hotkey.test.cjs
Normal 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
165
web/tests/i18n.test.cjs
Normal 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'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);
|
||||
}
|
||||
});
|
||||
740
web/tests/input_pill.test.cjs
Normal file
740
web/tests/input_pill.test.cjs
Normal 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");
|
||||
});
|
||||
113
web/tests/internal_url.test.cjs
Normal file
113
web/tests/internal_url.test.cjs
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
79
web/tests/keydown_util.test.cjs
Normal file
79
web/tests/keydown_util.test.cjs
Normal 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));
|
||||
});
|
||||
39
web/tests/lazy_set.test.cjs
Normal file
39
web/tests/lazy_set.test.cjs
Normal 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"));
|
||||
});
|
||||
159
web/tests/left_sidebar_navigation_area.test.cjs
Normal file
159
web/tests/left_sidebar_navigation_area.test.cjs
Normal 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"));
|
||||
});
|
||||
48
web/tests/lib/buddy_list.cjs
Normal file
48
web/tests/lib/buddy_list.cjs
Normal 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", []);
|
||||
};
|
||||
43
web/tests/lib/compose_banner.cjs
Normal file
43
web/tests/lib/compose_banner.cjs
Normal 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);
|
||||
};
|
||||
217
web/tests/lib/compose_helpers.cjs
Normal file
217
web/tests/lib/compose_helpers.cjs
Normal 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
1395
web/tests/lib/events.cjs
Normal file
File diff suppressed because it is too large
Load Diff
31
web/tests/lib/example_group.cjs
Normal file
31
web/tests/lib/example_group.cjs
Normal 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,
|
||||
};
|
||||
};
|
||||
161
web/tests/lib/example_realm.cjs
Normal file
161
web/tests/lib/example_realm.cjs
Normal 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,
|
||||
};
|
||||
};
|
||||
384
web/tests/lib/example_settings.cjs
Normal file
384
web/tests/lib/example_settings.cjs
Normal 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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
57
web/tests/lib/example_stream.cjs
Normal file
57
web/tests/lib/example_stream.cjs
Normal 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};
|
||||
};
|
||||
73
web/tests/lib/example_user.cjs
Normal file
73
web/tests/lib/example_user.cjs
Normal 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;
|
||||
51
web/tests/lib/handlebars.cjs
Normal file
51
web/tests/lib/handlebars.cjs
Normal 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
14
web/tests/lib/i18n.cjs
Normal 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
129
web/tests/lib/index.cjs
Normal 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;
|
||||
})();
|
||||
203
web/tests/lib/markdown_assert.cjs
Normal file
203
web/tests/lib/markdown_assert.cjs
Normal 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>"</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
149
web/tests/lib/mdiff.cjs
Normal 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);
|
||||
}
|
||||
19
web/tests/lib/message_list.cjs
Normal file
19
web/tests/lib/message_list.cjs
Normal 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;
|
||||
};
|
||||
11
web/tests/lib/mock_channel.cjs
Normal file
11
web/tests/lib/mock_channel.cjs
Normal 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
434
web/tests/lib/namespace.cjs
Normal 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
39
web/tests/lib/stub.cjs
Normal 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
55
web/tests/lib/test.cjs
Normal 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
123
web/tests/lib/zblueslip.cjs
Normal 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
98
web/tests/lib/zjquery.cjs
Normal 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());
|
||||
873
web/tests/lib/zjquery_element.cjs
Normal file
873
web/tests/lib/zjquery_element.cjs
Normal 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);
|
||||
}
|
||||
};
|
||||
12
web/tests/lib/zjquery_event.cjs
Normal file
12
web/tests/lib/zjquery_event.cjs
Normal file
@@ -0,0 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, props) {
|
||||
this.type = type;
|
||||
Object.assign(this, props);
|
||||
}
|
||||
preventDefault() {}
|
||||
stopPropagation() {}
|
||||
}
|
||||
|
||||
module.exports = FakeEvent;
|
||||
12
web/tests/lib/zpage_billing_params.cjs
Normal file
12
web/tests/lib/zpage_billing_params.cjs
Normal 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];
|
||||
}
|
||||
}
|
||||
};
|
||||
22
web/tests/lib/zpage_params.cjs
Normal file
22
web/tests/lib/zpage_params.cjs
Normal 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
275
web/tests/lightbox.test.cjs
Normal 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);
|
||||
});
|
||||
81
web/tests/linkifiers.test.cjs
Normal file
81
web/tests/linkifiers.test.cjs
Normal 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(), []);
|
||||
});
|
||||
161
web/tests/list_cursor.test.cjs
Normal file
161
web/tests/list_cursor.test.cjs
Normal 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"));
|
||||
});
|
||||
1033
web/tests/list_widget.test.cjs
Normal file
1033
web/tests/list_widget.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1078
web/tests/markdown.test.cjs
Normal file
1078
web/tests/markdown.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
392
web/tests/markdown_parse.test.cjs
Normal file
392
web/tests/markdown_parse.test.cjs
Normal 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 > 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
Reference in New Issue
Block a user