Files
upstream-zulip/web/tests/echo.test.cjs
upstream-mirror 64898b2c79
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
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

804 lines
26 KiB
JavaScript

"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]);
});