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

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

276
web/e2e-tests/admin.test.ts Normal file
View File

@@ -0,0 +1,276 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function submit_announcements_stream_settings(page: Page): Promise<void> {
await page.waitForSelector('#org-notifications .save-button[data-status="unsaved"]', {
visible: true,
});
const save_button = "#org-notifications .save-button";
await page.waitForFunction(
(save_button: string) => {
const button = document.querySelector(save_button);
return button && button.textContent?.trim() === "Save changes";
},
{},
save_button,
);
await page.click(save_button);
await page.waitForSelector('#org-notifications .save-button[data-status="saved"]', {
visible: true,
});
await page.waitForFunction(
(save_button: string) => {
const button = document.querySelector(save_button);
return button && button.textContent?.trim() === "Saved";
},
{},
save_button,
);
await page.waitForSelector("#org-notifications .save-button", {hidden: true});
}
async function test_change_new_stream_announcements_stream(page: Page): Promise<void> {
await page.waitForSelector(
"#realm_new_stream_announcements_stream_id_widget.dropdown-widget-button",
{visible: true},
);
await page.click("#realm_new_stream_announcements_stream_id_widget.dropdown-widget-button");
await page.waitForSelector(".dropdown-list-container", {
visible: true,
});
await page.waitForSelector(".dropdown-list-search-input", {visible: true});
await page.type(".dropdown-list-search-input", "rome");
const rome_in_dropdown = await page.waitForSelector(
`xpath///*[${common.has_class_x("list-item")}][normalize-space()="Rome"]`,
{visible: true},
);
assert.ok(rome_in_dropdown);
await rome_in_dropdown.click();
await submit_announcements_stream_settings(page);
}
async function test_change_signup_announcements_stream(page: Page): Promise<void> {
await page.waitForSelector(
"#realm_signup_announcements_stream_id_widget.dropdown-widget-button",
{visible: true},
);
await page.click("#realm_signup_announcements_stream_id_widget.dropdown-widget-button");
await page.waitForSelector(".dropdown-list-container", {
visible: true,
});
await page.waitForSelector(".dropdown-list-search-input", {visible: true});
await page.type(".dropdown-list-search-input", "rome");
const rome_in_dropdown = await page.waitForSelector(
`xpath///*[${common.has_class_x("list-item")}][normalize-space()="Rome"]`,
{visible: true},
);
assert.ok(rome_in_dropdown);
await rome_in_dropdown.click();
await submit_announcements_stream_settings(page);
}
async function test_change_zulip_update_announcements_stream(page: Page): Promise<void> {
await page.waitForSelector(
"#realm_zulip_update_announcements_stream_id_widget.dropdown-widget-button",
{visible: true},
);
await page.click("#realm_zulip_update_announcements_stream_id_widget.dropdown-widget-button");
await page.waitForSelector(".dropdown-list-container", {
visible: true,
});
await page.waitForSelector(".dropdown-list-search-input", {visible: true});
await page.type(".dropdown-list-search-input", "rome");
const rome_in_dropdown = await page.waitForSelector(
`xpath///*[${common.has_class_x("list-item")}][normalize-space()="Rome"]`,
{visible: true},
);
assert.ok(rome_in_dropdown);
await rome_in_dropdown.click();
await submit_announcements_stream_settings(page);
}
async function test_save_joining_organization_change_worked(page: Page): Promise<void> {
const saved_status = '#org-join-settings .save-button[data-status="saved"]';
await page.waitForSelector(saved_status, {
visible: true,
});
await page.waitForSelector(saved_status, {hidden: true});
}
async function submit_joining_organization_change(page: Page): Promise<void> {
const save_button = "#org-join-settings .save-button";
await page.waitForSelector(save_button, {visible: true});
assert.strictEqual(
await common.get_text_from_selector(page, save_button),
"Save changes",
"Save button didn't appear for permissions change.",
);
await page.waitForSelector(save_button, {visible: true});
await page.click(save_button);
await test_save_joining_organization_change_worked(page);
}
async function test_set_new_user_threshold_to_three_days(page: Page): Promise<void> {
console.log("Test setting new user threshold to three days.");
await page.waitForSelector("#id_realm_waiting_period_threshold", {visible: true});
await page.select("#id_realm_waiting_period_threshold", "3");
await submit_joining_organization_change(page);
}
async function test_set_new_user_threshold_to_N_days(page: Page): Promise<void> {
console.log("Test setting new user threshold to N days.");
await page.waitForSelector("#id_realm_waiting_period_threshold", {visible: true});
await page.select("#id_realm_waiting_period_threshold", "custom_period");
const N = "10";
await common.clear_and_type(page, "#id_realm_waiting_period_threshold_custom_input", N);
await submit_joining_organization_change(page);
}
async function test_organization_permissions(page: Page): Promise<void> {
await page.click("li[data-section='organization-permissions']");
// Test temporarily disabled 2024-02-25 due to nondeterminsitic failures.
// See https://chat.zulip.org/#narrow/channel/43-automated-testing/topic/main.20failing/near/1743361
console.log("Skipping", test_set_new_user_threshold_to_three_days);
console.log("Skipping", test_set_new_user_threshold_to_N_days);
}
async function test_add_emoji(page: Page): Promise<void> {
await common.fill_form(page, "#add-custom-emoji-form", {name: "zulip logo"});
const emoji_upload_handle = await page.$("input#emoji_file_input");
assert.ok(emoji_upload_handle);
await emoji_upload_handle.uploadFile("static/images/logo/zulip-icon-128x128.png");
await page.click("#add-custom-emoji-modal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector("tr#emoji_zulip_logo", {visible: true});
assert.strictEqual(
await common.get_text_from_selector(page, "tr#emoji_zulip_logo .emoji_name"),
"zulip logo",
"Emoji name incorrectly saved.",
);
await page.waitForSelector("tr#emoji_zulip_logo img", {visible: true});
}
async function test_delete_emoji(page: Page): Promise<void> {
await page.click("tr#emoji_zulip_logo button.delete");
await common.wait_for_micromodal_to_open(page);
await page.click("#confirm_deactivate_custom_emoji_modal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
// assert the emoji is deleted.
await page.waitForSelector("tr#emoji_zulip_logo", {hidden: true});
}
async function test_custom_realm_emoji(page: Page): Promise<void> {
await page.click("li[data-section='emoji-settings']");
await page.click("#add-custom-emoji-button");
await common.wait_for_micromodal_to_open(page);
await test_add_emoji(page);
await test_delete_emoji(page);
}
async function test_upload_realm_icon_image(page: Page): Promise<void> {
const upload_handle = await page.$("#realm-icon-upload-widget input.image_file_input");
assert.ok(upload_handle);
await upload_handle.uploadFile("static/images/logo/zulip-icon-128x128.png");
await common.wait_for_micromodal_to_open(page);
await page.click("#uppy-editor .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(
'#realm-icon-upload-widget .image-block[src^="/user_avatars/2/realm/icon.png?version=2"]',
{visible: true},
);
}
async function delete_realm_icon(page: Page): Promise<void> {
await page.click("li[data-section='organization-profile']");
await page.click("#realm-icon-upload-widget .image-delete-button");
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {hidden: true});
}
async function test_organization_profile(page: Page): Promise<void> {
await page.click("li[data-section='organization-profile']");
const gravatar_selector =
'#realm-icon-upload-widget .image-block[src^="https://secure.gravatar.com/avatar/"]';
await page.waitForSelector(gravatar_selector, {visible: true});
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {hidden: true});
await test_upload_realm_icon_image(page);
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {visible: true});
await delete_realm_icon(page);
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {hidden: true});
await page.waitForSelector(gravatar_selector, {visible: true});
}
async function test_authentication_methods(page: Page): Promise<void> {
await page.click("li[data-section='auth-methods']");
await page.waitForSelector(".method_row[data-method='Google'] input[type='checkbox'] + span", {
visible: true,
});
await page.click(".method_row[data-method='Google'] input[type='checkbox'] + span");
const save_button = "#org-auth_settings .save-button";
assert.strictEqual(await common.get_text_from_selector(page, save_button), "Save changes");
await page.click(save_button);
// Leave the page and return.
const settings_dropdown = "#settings-dropdown";
await page.click(settings_dropdown);
await common.manage_organization(page);
await page.click("li[data-section='auth-methods']");
// Test setting was saved.
await page.waitForSelector(".method_row[data-method='Google'] input[type='checkbox'] + span", {
visible: true,
});
await page.waitForSelector(
".method_row[data-method='Google'] input[type='checkbox']:not(:checked)",
);
}
async function admin_test(page: Page): Promise<void> {
await common.log_in(page);
await common.manage_organization(page);
await test_change_new_stream_announcements_stream(page);
await test_change_signup_announcements_stream(page);
await test_change_zulip_update_announcements_stream(page);
await test_organization_permissions(page);
// Currently, Firefox (with puppeteer) does not support file upload:
// https://github.com/puppeteer/puppeteer/issues/6688.
// Until that is resolved upstream, we need to skip the tests that involve
// doing file upload on Firefox.
if (!common.is_firefox) {
await test_custom_realm_emoji(page);
await test_organization_profile(page);
}
await test_authentication_methods(page);
}
await common.run_test(admin_test);

View File

@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function check_compose_form_empty(page: Page): Promise<void> {
await common.check_compose_state(page, {
stream_name: "",
topic: "",
content: "",
});
}
async function close_compose_box(page: Page): Promise<void> {
const recipient_dropdown_visible = (await page.$(".dropdown-list-container")) !== null;
if (recipient_dropdown_visible) {
await page.keyboard.press("Escape");
await page.waitForSelector(".dropdown-list-container", {hidden: true});
}
await page.keyboard.press("Escape");
await page.waitForSelector("#compose-textarea", {hidden: true});
}
function get_message_selector(text: string): string {
return `xpath/(//p[text()='${text}'])[last()]`;
}
async function test_send_messages(page: Page): Promise<void> {
const initial_msgs_count = (await page.$$(".message-list .message_row")).length;
await common.send_multiple_messages(page, [
{stream_name: "Verona", topic: "Reply test", content: "Compose stream reply test"},
{recipient: "cordelia@zulip.com", content: "Compose direct message reply test"},
]);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
assert.equal((await page.$$(".message-list .message_row")).length, initial_msgs_count + 2);
}
async function test_stream_compose_keyboard_shortcut(page: Page): Promise<void> {
await page.keyboard.press("KeyC");
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
await check_compose_form_empty(page);
await close_compose_box(page);
}
async function test_private_message_compose_shortcut(page: Page): Promise<void> {
await page.keyboard.press("KeyX");
await page.waitForSelector("#private_message_recipient", {visible: true});
await common.pm_recipient.expect(page, "");
await close_compose_box(page);
}
async function test_keyboard_shortcuts(page: Page): Promise<void> {
await test_stream_compose_keyboard_shortcut(page);
await test_private_message_compose_shortcut(page);
}
async function test_reply_by_click_prepopulates_stream_topic_names(page: Page): Promise<void> {
const stream_message_selector = get_message_selector("Compose stream reply test");
const stream_message = await page.waitForSelector(stream_message_selector, {visible: true});
assert.ok(stream_message !== null);
// we chose only the last element make sure we don't click on any duplicates.
await stream_message.click();
await common.check_compose_state(page, {
stream_name: "Verona",
topic: "Reply test",
content: "",
});
await close_compose_box(page);
}
async function test_reply_by_click_prepopulates_private_message_recipient(
page: Page,
): Promise<void> {
const private_message = await page.$(get_message_selector("Compose direct message reply test"));
assert.ok(private_message !== null);
await private_message.click();
await page.waitForSelector("#private_message_recipient", {visible: true});
const email = await common.get_internal_email_from_name(page, common.fullname.cordelia);
assert.ok(email !== undefined);
await common.pm_recipient.expect(page, email);
await close_compose_box(page);
}
async function test_reply_with_r_shortcut(page: Page): Promise<void> {
// The last message(private) in the narrow is currently selected as a result of previous tests.
// Now we go up and open compose box with r key.
await page.keyboard.press("KeyK");
await page.keyboard.press("KeyR");
await common.check_compose_state(page, {
stream_name: "Verona",
topic: "Reply test",
content: "",
});
}
async function test_open_close_compose_box(page: Page): Promise<void> {
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
await close_compose_box(page);
await page.waitForSelector("#stream_message_recipient_topic", {hidden: true});
await page.keyboard.press("KeyX");
await page.waitForSelector("#compose-direct-recipient", {visible: true});
await close_compose_box(page);
await page.waitForSelector("#compose-direct-recipient", {hidden: true});
}
async function test_narrow_to_private_messages_with_cordelia(page: Page): Promise<void> {
const you_and_cordelia_selector =
'*[data-tippy-content="Go to direct messages with Cordelia, Lear\'s daughter"]';
// For some unknown reason page.click() isn't working here.
await page.evaluate((selector: string) => {
document.querySelector<HTMLElement>(selector)!.click();
}, you_and_cordelia_selector);
const cordelia_user_id = await common.get_user_id_from_name(page, "Cordelia, Lear's daughter");
const pm_list_selector = `li[data-user-ids-string="${cordelia_user_id}"].dm-list-item.active-sub-filter`;
await page.waitForSelector(pm_list_selector, {visible: true});
await close_compose_box(page);
await page.keyboard.press("KeyC");
await page.waitForSelector("#compose", {visible: true});
await page.waitForSelector(`.dropdown-list-container .list-item`, {visible: true});
await close_compose_box(page);
}
async function test_send_multirecipient_pm_from_cordelia_pm_narrow(page: Page): Promise<void> {
const recipients = ["cordelia@zulip.com", "othello@zulip.com"];
const multiple_recipients_pm = "A direct message group to check spaces";
await common.send_message(page, "private", {
recipient: recipients.join(", "),
content: multiple_recipients_pm,
});
// Go back to the combined feed view and make sure all messages are loaded.
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
const pm = await page.waitForSelector(
`xpath/(//*[${common.has_class_x(
"messagebox",
)} and contains(normalize-space(), "${multiple_recipients_pm}") and count(.//*[${common.has_class_x(
"star",
)}])>0])[last()]`,
);
assert.ok(pm !== null);
await pm.click();
await page.waitForSelector("#compose-textarea", {visible: true});
const recipient_internal_emails = [
await common.get_internal_email_from_name(page, common.fullname.cordelia),
await common.get_internal_email_from_name(page, common.fullname.othello),
].join(",");
await common.pm_recipient.expect(page, recipient_internal_emails);
}
const markdown_preview_button = "#compose .markdown_preview";
const markdown_preview_hide_button = "#compose .undo_markdown_preview";
async function test_markdown_preview_buttons_visibility(page: Page): Promise<void> {
await page.waitForSelector(markdown_preview_button, {visible: true});
await page.waitForSelector(markdown_preview_hide_button, {hidden: true});
// verify if Markdown preview button works.
await page.click(markdown_preview_button);
await page.waitForSelector(markdown_preview_button, {hidden: true});
await page.waitForSelector(markdown_preview_hide_button, {visible: true});
// verify if hide button works.
await page.click(markdown_preview_hide_button);
await page.waitForSelector(markdown_preview_button, {visible: true});
await page.waitForSelector(markdown_preview_hide_button, {hidden: true});
}
async function test_markdown_preview_without_any_content(page: Page): Promise<void> {
await page.click("#compose .markdown_preview");
await page.waitForSelector("#compose .undo_markdown_preview", {visible: true});
const markdown_preview_element = await page.$("#compose .preview_content");
assert.ok(markdown_preview_element);
assert.equal(
await page.evaluate((element: Element) => element.textContent, markdown_preview_element),
"Nothing to preview",
);
await page.click("#compose .undo_markdown_preview");
}
async function test_markdown_rendering(page: Page): Promise<void> {
await page.waitForSelector("#compose .markdown_preview", {visible: true});
assert.equal(await common.get_text_from_selector(page, "#compose .preview_content"), "");
await common.fill_form(page, 'form[action^="/json/messages"]', {
content: "**Markdown preview** >> Test for Markdown preview",
});
await page.click("#compose .markdown_preview");
const preview_content = await page.waitForSelector(
`xpath///*[@id="compose"]//*[${common.has_class_x(
"preview_content",
)} and normalize-space()!=""]`,
{visible: true},
);
assert.ok(preview_content !== null);
const expected_markdown_html =
"<p><strong>Markdown preview</strong> &gt;&gt; Test for Markdown preview</p>";
assert.equal(
await (await preview_content.getProperty("innerHTML")).jsonValue(),
expected_markdown_html,
);
}
async function test_markdown_preview(page: Page): Promise<void> {
await test_markdown_preview_buttons_visibility(page);
await test_markdown_preview_without_any_content(page);
await test_markdown_rendering(page);
}
async function compose_tests(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
await test_send_messages(page);
await test_keyboard_shortcuts(page);
await test_reply_by_click_prepopulates_stream_topic_names(page);
await test_reply_by_click_prepopulates_private_message_recipient(page);
await test_reply_with_r_shortcut(page);
await test_open_close_compose_box(page);
await test_narrow_to_private_messages_with_cordelia(page);
await test_send_multirecipient_pm_from_cordelia_pm_narrow(page);
await test_markdown_preview(page);
}
await common.run_test(compose_tests);

View File

@@ -0,0 +1,313 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
type PartialSelectionConfig = {
select_start_message_partially: boolean;
select_end_message_partially: boolean;
start_text_node_offset?: number;
end_text_node_offset?: number;
};
async function copy_messages(
page: Page,
start_message: string,
end_message: string,
partial_selection_config?: PartialSelectionConfig,
): Promise<string[]> {
return await page.evaluate(
(
start_message: string,
end_message: string,
partial_selection_config?: PartialSelectionConfig,
) => {
function get_message_node(message: string): Element {
return [...document.querySelectorAll(".message-list .message_content")].find(
(node) => node.textContent?.trim() === message,
)!;
}
// select messages from start_message to end_message
const selectedRange = document.createRange();
if (partial_selection_config?.select_start_message_partially) {
const offset = partial_selection_config.start_text_node_offset!;
const start_message_text_node =
get_message_node(start_message).querySelector("p")?.firstChild;
if (!(start_message_text_node instanceof Text)) {
throw new TypeError("Expected a Text node");
}
selectedRange.setStart(start_message_text_node, offset);
} else {
selectedRange.setStartBefore(get_message_node(start_message));
}
if (partial_selection_config?.select_end_message_partially) {
const offset = partial_selection_config.end_text_node_offset!;
const end_message_text_node =
get_message_node(end_message).querySelector("p")?.firstChild;
if (!(end_message_text_node instanceof Text)) {
throw new TypeError("Expected a Text node");
}
// For the last message, the offset will be from the end of the message,
// just like how selecting text in the browser would work.
selectedRange.setEnd(end_message_text_node, end_message_text_node.length - offset);
} else {
selectedRange.setEndAfter(get_message_node(end_message));
}
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(selectedRange);
// emulate copy event
const clipboard_data = new DataTransfer();
const copy_event = new ClipboardEvent("copy", {
bubbles: true,
cancelable: true,
clipboardData: clipboard_data,
});
document.dispatchEvent(copy_event);
const copied_html = clipboard_data.getData("text/html");
// Convert the copied HTML into separate message strings
const parser = new DOMParser();
const doc = parser.parseFromString(copied_html, "text/html");
// Empty paragraphs are inserted only to separate consecutive
// messages with a blank line; skip them when collecting lines.
return [...doc.body.children]
.map((el) => el.textContent.trim())
.filter((line) => line !== "");
},
start_message,
end_message,
partial_selection_config,
);
}
async function test_copying_first_message_from_topic(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test C", "copy paste test C");
const expected_copied_lines: string[] = [];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_copying_last_message_from_topic(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test E", "copy paste test E");
const expected_copied_lines: string[] = [];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_copying_first_two_messages_from_topic(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test C", "copy paste test D");
const expected_copied_lines = [
"Desdemona:",
"copy paste test C",
"Desdemona:",
"copy paste test D",
];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_copying_all_messages_from_topic(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test C", "copy paste test E");
const expected_copied_lines = [
"Desdemona:",
"copy paste test C",
"Desdemona:",
"copy paste test D",
"Desdemona:",
"copy paste test E",
];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_copying_last_from_prev_first_from_next(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test B", "copy paste test C");
const expected_copied_lines = [
"Verona > copy-paste-topic #1 | Today",
"Desdemona:",
"copy paste test B",
"Verona > copy-paste-topic #2 | Today",
"Desdemona:",
"copy paste test C",
];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_copying_last_from_prev_all_from_next(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test B", "copy paste test E");
const expected_copied_lines = [
"Verona > copy-paste-topic #1 | Today",
"Desdemona:",
"copy paste test B",
"Verona > copy-paste-topic #2 | Today",
"Desdemona:",
"copy paste test C",
"Desdemona:",
"copy paste test D",
"Desdemona:",
"copy paste test E",
];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_copying_all_from_prev_first_from_next(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test A", "copy paste test C");
const expected_copied_lines = [
"Verona > copy-paste-topic #1 | Today",
"Desdemona:",
"copy paste test A",
"Desdemona:",
"copy paste test B",
"Verona > copy-paste-topic #2 | Today",
"Desdemona:",
"copy paste test C",
];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_copying_messages_from_several_topics(page: Page): Promise<void> {
const actual_copied_lines = await copy_messages(page, "copy paste test B", "copy paste test F");
const expected_copied_lines = [
"Verona > copy-paste-topic #1 | Today",
"Desdemona:",
"copy paste test B",
"Verona > copy-paste-topic #2 | Today",
"Desdemona:",
"copy paste test C",
"Desdemona:",
"copy paste test D",
"Desdemona:",
"copy paste test E",
"Verona > copy-paste-topic #3 | Today",
"Desdemona:",
"copy paste test F",
];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function test_timestamp_clipboard_has_datetime(page: Page): Promise<void> {
// Verify that copying a rendered timestamp injects <span data-datetime> into the
// selection HTML so Chrome's clipboard serializer cannot silently drop the datetime.
const copied_html = await page.evaluate(() => {
const time_el = document.querySelector<HTMLElement>(
'.message-list time[datetime="2026-05-23T17:30:00Z"]',
);
if (!time_el) {
return null;
}
const range = document.createRange();
range.selectNodeContents(time_el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
// Dispatch copy: copy_handler runs improve_time_selection_range, which
// injects <span data-datetime> into the DOM and expands the range to
// cover the full <time>. For a single-message selection copy_handler
// returns false (browser handles clipboard natively), so the DataTransfer
// stays empty — but the selection range now contains the mutated DOM.
document.dispatchEvent(
new ClipboardEvent("copy", {
bubbles: true,
cancelable: true,
clipboardData: new DataTransfer(),
}),
);
// Serialize the mutated, expanded selection: this is what Chrome writes
// to the clipboard. Even when Chrome strips <time>, <span data-datetime>
// survives as a plain span and the paste handler can recover <time:ISO>.
const div = document.createElement("div");
div.append(window.getSelection()!.getRangeAt(0).cloneContents());
return div.innerHTML;
});
assert.ok(
copied_html?.includes('data-datetime="2026-05-23T17:30:00Z"'),
`Expected data-datetime="2026-05-23T17:30:00Z" in clipboard HTML, got: ${copied_html}`,
);
}
async function test_multiple_message_selection_with_partially_selected_bookend_messages(
page: Page,
): Promise<void> {
const actual_copied_lines = await copy_messages(
page,
"copy paste test B",
"copy paste test F",
{
select_start_message_partially: true,
select_end_message_partially: true,
start_text_node_offset: 5,
end_text_node_offset: 7,
},
);
const expected_copied_lines = [
"Verona > copy-paste-topic #1 | Today",
"Desdemona:",
// w/o partial selection: "copy paste test B",
"...paste test B",
"Verona > copy-paste-topic #2 | Today",
"Desdemona:",
"copy paste test C",
"Desdemona:",
"copy paste test D",
"Desdemona:",
"copy paste test E",
"Verona > copy-paste-topic #3 | Today",
"Desdemona:",
// w/o partial selection: "copy paste test F",
"copy paste...",
];
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
}
async function copy_paste_test(page: Page): Promise<void> {
await common.log_in(page);
await common.send_multiple_messages(page, [
{
stream_name: "Verona",
topic: "copy-paste-topic #0",
content: "<time:2026-05-23T17:30:00Z>",
},
{stream_name: "Verona", topic: "copy-paste-topic #1", content: "copy paste test A"},
{stream_name: "Verona", topic: "copy-paste-topic #1", content: "copy paste test B"},
{stream_name: "Verona", topic: "copy-paste-topic #2", content: "copy paste test C"},
{stream_name: "Verona", topic: "copy-paste-topic #2", content: "copy paste test D"},
{stream_name: "Verona", topic: "copy-paste-topic #2", content: "copy paste test E"},
{stream_name: "Verona", topic: "copy-paste-topic #3", content: "copy paste test F"},
{stream_name: "Verona", topic: "copy-paste-topic #3", content: "copy paste test G"},
]);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await common.check_messages_sent(page, message_list_id, [
["Verona > copy-paste-topic #0", ["Sat, May 23, 2026, 5:30 PM"]],
["Verona > copy-paste-topic #1", ["copy paste test A", "copy paste test B"]],
[
"Verona > copy-paste-topic #2",
["copy paste test C", "copy paste test D", "copy paste test E"],
],
["Verona > copy-paste-topic #3", ["copy paste test F", "copy paste test G"]],
]);
console.log("Messages were sent successfully");
await test_copying_first_message_from_topic(page);
await test_copying_last_message_from_topic(page);
await test_copying_first_two_messages_from_topic(page);
await test_copying_all_messages_from_topic(page);
await test_copying_last_from_prev_first_from_next(page);
await test_copying_last_from_prev_all_from_next(page);
await test_copying_all_from_prev_first_from_next(page);
await test_copying_messages_from_several_topics(page);
await test_timestamp_clipboard_has_datetime(page);
await test_multiple_message_selection_with_partially_selected_bookend_messages(page);
}
await common.run_test(copy_paste_test);

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
// This will be the row of the custom profile field we add.
const profile_field_row = "#admin_profile_fields_table tr:nth-last-child(1)";
async function test_add_new_profile_field(page: Page): Promise<void> {
await page.click("#add-custom-profile-field-button");
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Add a new custom profile field",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
"Add",
);
await page.waitForSelector(".admin-profile-field-form", {visible: true});
await common.fill_form(page, "form.admin-profile-field-form", {
field_type: "1",
name: "Teams",
});
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(
'xpath///*[@id="admin_profile_fields_table"]//tr[last()]/td[normalize-space()="Teams"]',
);
assert.strictEqual(
await common.get_text_from_selector(page, `${profile_field_row} span.profile_field_type`),
"Short text",
);
}
async function test_edit_profile_field(page: Page): Promise<void> {
await page.click(`${profile_field_row} button.open-edit-form-modal`);
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Edit custom profile field",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
"Save changes",
);
await common.fill_form(page, "form.name-setting", {
name: "team",
});
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(
'xpath///*[@id="admin_profile_fields_table"]//tr[last()]/td[normalize-space()="team"]',
);
assert.strictEqual(
await common.get_text_from_selector(page, `${profile_field_row} span.profile_field_type`),
"Short text",
);
}
async function test_delete_custom_profile_field(page: Page): Promise<void> {
await page.click(`${profile_field_row} button.delete`);
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Delete custom profile field?",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
"Delete",
);
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector("#admin-profile-field-status img", {visible: true});
assert.strictEqual(
await common.get_text_from_selector(page, "div#admin-profile-field-status"),
"Saved",
);
}
async function test_custom_profile(page: Page): Promise<void> {
await common.log_in(page);
await common.manage_organization(page);
console.log("Testing custom profile fields");
await page.click("li[data-section='profile-field-settings']");
await test_add_new_profile_field(page);
await test_edit_profile_field(page);
await test_delete_custom_profile_field(page);
}
await common.run_test(test_custom_profile);

View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function click_delete_and_return_last_msg_id(page: Page): Promise<string> {
const msg = (await page.$$(".message-list .message_row")).at(-1);
assert.ok(msg !== undefined);
const id = await (await msg.getProperty("id")).jsonValue();
await msg.hover();
const info = await page.waitForSelector(
`#${CSS.escape(id)} .message_control_button.actions_hover`,
{visible: true},
);
assert.ok(info !== null);
await info.click();
await page.waitForSelector(".delete_message", {visible: true});
await page.click(".delete_message");
return id;
}
async function delete_message_test(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
// Assert that there is only one message list.
assert.equal((await page.$$(".message-list")).length, 1);
const messages_quantity = (await page.$$(".message-list .message_row")).length;
const last_message_id = await click_delete_and_return_last_msg_id(page);
await common.wait_for_micromodal_to_open(page);
await page.evaluate(() => {
document.querySelector<HTMLButtonElement>(".dialog_submit_button")?.click();
});
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(`#${CSS.escape(last_message_id)}`, {hidden: true});
assert.equal((await page.$$(".message-list .message_row")).length, messages_quantity - 1);
}
await common.run_test(delete_message_test);

View File

@@ -0,0 +1,329 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function wait_for_drafts_to_disappear(page: Page): Promise<void> {
await page.waitForSelector("#draft_overlay.show", {hidden: true});
}
async function wait_for_drafts_to_appear(page: Page): Promise<void> {
await page.waitForSelector("#draft_overlay.show", {visible: true});
}
async function get_drafts_count(page: Page): Promise<number> {
return await page.$$eval("#drafts_table .overlay-message-row", (drafts) => drafts.length);
}
const drafts_button = ".top_left_drafts";
const drafts_overlay = "#draft_overlay";
async function test_empty_drafts(page: Page): Promise<void> {
await page.waitForSelector(drafts_button, {visible: true});
await page.click(drafts_button);
await wait_for_drafts_to_appear(page);
await page.waitForSelector(drafts_overlay, {visible: true});
assert.strictEqual(await common.get_text_from_selector(page, ".drafts-list"), "No drafts.");
await page.click(`${drafts_overlay} .exit`);
await wait_for_drafts_to_disappear(page);
}
async function create_stream_message_draft(page: Page): Promise<void> {
console.log("Creating stream message draft");
await page.keyboard.press("KeyC");
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
await common.select_stream_in_compose_via_dropdown(page, "Denmark");
await common.fill_form(page, "form#send_message_form", {
content: "Test stream message.",
});
await page.type("#stream_message_recipient_topic", "tests", {delay: 100});
await page.click("#compose_close");
}
async function test_restore_stream_message_draft_by_opening_compose_box(page: Page): Promise<void> {
await page.click(".search_icon");
await page.waitForSelector("#search_query", {visible: true});
await common.clear_and_type(page, "#search_query", "stream:Denmark topic:tests");
await page.keyboard.press("Enter");
// Wait for narrow to complete.
const wait_for_change = true;
await common.get_current_msg_list_id(page, wait_for_change);
await page.waitForSelector("#left_bar_compose_reply_button_big", {visible: true});
await page.click("#left_bar_compose_reply_button_big");
await page.waitForSelector("#send_message_form", {visible: true});
await common.check_compose_state(page, {
stream_name: "Denmark",
topic: "tests",
content: "Test stream message. ",
});
await page.click("#compose_close");
await page.waitForSelector("#send_message_form", {visible: false});
}
async function create_private_message_draft(page: Page): Promise<void> {
console.log("Creating direct message draft");
await page.keyboard.press("KeyX");
await page.waitForSelector("#private_message_recipient", {visible: true});
await common.fill_form(page, "form#send_message_form", {content: "Test direct message."});
await common.pm_recipient.set(page, "cordelia@zulip.com");
await common.pm_recipient.set(page, "hamlet@zulip.com");
}
async function test_restore_private_message_draft_by_opening_composebox(page: Page): Promise<void> {
await page.waitForSelector("#left_bar_compose_reply_button_big", {visible: true});
await page.click("#left_bar_compose_reply_button_big");
await page.waitForSelector("#private_message_recipient", {visible: true});
await common.check_form_contents(page, "form#send_message_form", {
content: "Test direct message. ",
});
await page.click("#compose_close");
await page.waitForSelector("#private_message_recipient", {visible: false});
}
async function open_compose_markdown_preview(page: Page): Promise<void> {
const new_conversation_button = "#new_conversation_button";
await page.waitForSelector(new_conversation_button, {visible: true});
await page.click(new_conversation_button);
const markdown_preview_button = "#compose .markdown_preview"; // eye icon.
await page.waitForSelector(markdown_preview_button, {visible: true});
await page.click(markdown_preview_button);
}
async function open_drafts_after_markdown_preview(page: Page): Promise<void> {
await open_compose_markdown_preview(page);
await page.waitForSelector(drafts_button, {visible: true});
await page.click(drafts_button);
await wait_for_drafts_to_appear(page);
}
async function test_previously_created_drafts_rendered(page: Page): Promise<void> {
const drafts_count = await get_drafts_count(page);
assert.strictEqual(drafts_count, 2, "Drafts improperly loaded.");
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_header_stream .stream_label",
),
"Denmark",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_header_stream .stream_topic",
),
"tests",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .private-message .rendered_markdown.restore-overlay-message",
),
"Test direct message.",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_header_private_message .stream_label",
),
"You and Cordelia, Lear's daughter, King Hamlet",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_row:not(.private-message) .rendered_markdown.restore-overlay-message",
),
"Test stream message.",
);
}
async function test_restore_message_draft_via_draft_overlay(page: Page): Promise<void> {
console.log("Restoring stream message draft");
await page.click("#drafts_table .message_row:not(.private-message) .restore-overlay-message");
await wait_for_drafts_to_disappear(page);
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
await page.waitForSelector("#preview_message_area", {hidden: true});
await common.check_compose_state(page, {
stream_name: "Denmark",
topic: "tests",
content: "Test stream message.",
});
assert.strictEqual(
await common.get_text_from_selector(page, "title"),
"#Denmark > tests - Zulip Dev - Zulip",
"Didn't narrow to the right topic.",
);
}
async function edit_stream_message_draft(page: Page): Promise<void> {
await common.select_stream_in_compose_via_dropdown(page, "Denmark");
await common.fill_form(page, "form#send_message_form", {
content: "Updated stream message",
});
await page.click("#compose_close");
}
async function test_edited_draft_message(page: Page): Promise<void> {
await page.waitForSelector(drafts_button, {visible: true});
await page.click(drafts_button);
await wait_for_drafts_to_appear(page);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_header_stream .stream_label",
),
"Denmark",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_header_stream .stream_topic",
),
"tests",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_row:not(.private-message) .rendered_markdown.restore-overlay-message",
),
"Updated stream message",
);
}
async function test_restore_private_message_draft_via_draft_overlay(page: Page): Promise<void> {
console.log("Restoring direct message draft.");
await page.click(".message_row.private-message .restore-overlay-message");
await wait_for_drafts_to_disappear(page);
await page.waitForSelector("#compose-direct-recipient", {visible: true});
await common.check_compose_state(page, {
content: "Test direct message.",
});
const cordelia_internal_email = await common.get_internal_email_from_name(
page,
common.fullname.cordelia,
);
const hamlet_internal_email = await common.get_internal_email_from_name(
page,
common.fullname.hamlet,
);
await common.pm_recipient.expect(page, `${cordelia_internal_email},${hamlet_internal_email}`);
assert.strictEqual(
await common.get_text_from_selector(page, "title"),
"Cordelia, Lear's daughter and King Hamlet - Zulip Dev - Zulip",
"Didn't narrow to the direct messages with cordelia and hamlet",
);
await page.click("#compose_close");
}
async function test_delete_draft(page: Page): Promise<void> {
console.log("Deleting draft");
await page.waitForSelector(drafts_button, {visible: true});
await page.click(drafts_button);
await wait_for_drafts_to_appear(page);
await page.click("#drafts_table .message_row.private-message .delete-overlay-message");
const drafts_count = await get_drafts_count(page);
assert.strictEqual(drafts_count, 1, "Draft not deleted.");
await page.waitForSelector("#drafts_table .message_row.private-message", {hidden: true});
await page.click(`${drafts_overlay} .exit`);
await wait_for_drafts_to_disappear(page);
await page.click("body");
}
async function test_save_draft_by_reloading(page: Page): Promise<void> {
console.log("Saving draft by reloading.");
await page.keyboard.press("KeyX");
await page.waitForSelector("#compose-direct-recipient", {visible: true});
await common.fill_form(page, "form#send_message_form", {
content: "Test direct message draft.",
});
await common.pm_recipient.set(page, "cordelia@zulip.com");
await page.reload();
// Reloading into a direct messages narrow opens compose box.
await page.waitForSelector("#compose-textarea", {visible: true});
await page.click("#compose_close");
console.log("Reloading finished. Opening drafts again now.");
await page.waitForSelector(drafts_button, {visible: true});
await page.click(drafts_button);
console.log("Checking drafts survived after the reload");
await wait_for_drafts_to_appear(page);
const drafts_count = await get_drafts_count(page);
assert.strictEqual(drafts_count, 2, "All drafts aren't loaded.");
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row .message_header_private_message .stream_label",
),
"You and Cordelia, Lear's daughter",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
"#drafts_table .overlay-message-row:nth-last-child(2) .rendered_markdown.restore-overlay-message",
),
"Test direct message draft.",
);
}
async function test_delete_draft_on_clearing_text(page: Page): Promise<void> {
console.log("Deleting draft by clearing compose box textarea.");
await page.click("#drafts_table .message_row:not(.private-message) .restore-overlay-message");
await wait_for_drafts_to_disappear(page);
await page.waitForSelector("#send_message_form", {visible: true});
await common.fill_form(page, "form#send_message_form", {content: ""});
await page.click("#compose_close");
await page.waitForSelector("#send_message_form", {hidden: true});
await page.click(drafts_button);
await wait_for_drafts_to_appear(page);
const drafts_count = await get_drafts_count(page);
assert.strictEqual(drafts_count, 1, "Draft not deleted.");
}
async function drafts_test(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
await test_empty_drafts(page);
await create_stream_message_draft(page);
await test_restore_stream_message_draft_by_opening_compose_box(page);
// Send a private message so that the draft we create is
// for an existing conversation.
await common.send_message(page, "private", {
recipient: "cordelia@zulip.com, hamlet@zulip.com",
content: "howdy doo",
});
await create_private_message_draft(page);
// Close and try restoring it by opening the composebox again.
await page.click("#compose_close");
await test_restore_private_message_draft_by_opening_composebox(page);
await open_drafts_after_markdown_preview(page);
await test_previously_created_drafts_rendered(page);
await test_restore_message_draft_via_draft_overlay(page);
await edit_stream_message_draft(page);
await test_edited_draft_message(page);
await test_restore_private_message_draft_via_draft_overlay(page);
await test_delete_draft(page);
await test_save_draft_by_reloading(page);
await test_delete_draft_on_clearing_text(page);
}
await common.run_test(drafts_test);

117
web/e2e-tests/edit.test.ts Normal file
View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function trigger_edit_last_message(page: Page): Promise<void> {
const msg = (await page.$$(".message-list .message_row")).at(-1);
assert.ok(msg !== undefined);
const id = await (await msg.getProperty("id")).jsonValue();
await msg.hover();
const info = await page.waitForSelector(
`#${CSS.escape(id)} .message_control_button.actions_hover`,
{visible: true},
);
assert.ok(info !== null);
await info.click();
await page.waitForSelector(".popover_edit_message", {visible: true});
await page.click(".popover_edit_message");
await page.waitForSelector(".message_edit_content", {visible: true});
}
async function edit_stream_message(page: Page, content: string): Promise<void> {
await trigger_edit_last_message(page);
await common.clear_and_type(page, ".message_edit_content", content);
await page.click(".message_edit_save");
await common.wait_for_fully_processed_message(page, content);
}
async function test_stream_message_edit(page: Page): Promise<void> {
await common.send_message(page, "stream", {
stream_name: "Verona",
topic: "edits",
content: "test editing",
});
await edit_stream_message(page, "test edited");
const message_list_id = await common.get_current_msg_list_id(page, false);
await common.check_messages_sent(page, message_list_id, [["Verona > edits", ["test edited"]]]);
}
async function test_edit_message_with_slash_me(page: Page): Promise<void> {
const last_message_xpath = `(//*[${common.has_class_x("message-list")}]//*[${common.has_class_x(
"messagebox",
)}])[last()]`;
await common.send_message(
page,
"stream",
{
stream_name: "Verona",
topic: "edits",
content: "/me test editing a message with me",
},
// We already narrow in test_stream_message_edit.
false,
);
await page.waitForSelector(
`xpath/${last_message_xpath}//*[${common.has_class_x(
"status-message",
)} and text()="test editing a message with me"]`,
);
await page.waitForSelector(
`xpath/${last_message_xpath}//*[${common.has_class_x(
"sender_name",
)} and normalize-space()="Desdemona"]`,
);
await edit_stream_message(page, "/me test edited a message with me");
await page.waitForSelector(
`xpath/${last_message_xpath}//*[${common.has_class_x(
"status-message",
)} and text()="test edited a message with me"]`,
);
await page.waitForSelector(
`xpath/${last_message_xpath}//*[${common.has_class_x(
"sender_name",
)} and normalize-space()="Desdemona"]`,
);
}
async function test_edit_private_message(page: Page): Promise<void> {
await common.send_message(page, "private", {
recipient: "cordelia@zulip.com",
content: "test editing pm",
});
await trigger_edit_last_message(page);
await common.clear_and_type(page, ".message_edit_content", "test edited pm");
await page.click(".message_edit_save");
await common.wait_for_fully_processed_message(page, "test edited pm");
const message_list_id = await common.get_current_msg_list_id(page, false);
await common.check_messages_sent(page, message_list_id, [
["You and Cordelia, Lear's daughter", ["test edited pm"]],
]);
}
async function edit_tests(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
await test_stream_message_edit(page);
await test_edit_message_with_slash_me(page);
await test_edit_private_message(page);
}
await common.run_test(edit_tests);

756
web/e2e-tests/lib/common.ts Normal file
View File

@@ -0,0 +1,756 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import path from "node:path";
import timersPromises from "node:timers/promises";
import * as url from "node:url";
import "css.escape";
import ErrorStackParser from "error-stack-parser";
import type {Browser, ConsoleMessage, ConsoleMessageLocation, ElementHandle, Page} from "puppeteer";
import * as puppeteer from "puppeteer";
import StackFrame from "stackframe";
import StackTraceGPS from "stacktrace-gps";
import * as z from "zod/mini";
const root_dir = url.fileURLToPath(new URL("../../..", import.meta.url));
const puppeteer_dir = path.join(root_dir, "var/puppeteer");
export const test_credentials = z
.object({default_user: z.object({username: z.string(), password: z.string()})})
.parse(JSON.parse(fs.readFileSync(path.join(puppeteer_dir, "test_credentials.json"), "utf8")));
type Message = Record<string, string | boolean> & {
recipient?: string;
content: string;
stream_name?: string;
topic?: string;
};
let browser: Browser | null = null;
let screenshot_id = 0;
export const is_firefox = process.env["PUPPETEER_PRODUCT"] === "firefox";
let realm_url = "http://zulip.zulipdev.com:9981/";
const gps = new StackTraceGPS({ajax: async (url) => (await fetch(url)).text()});
let last_current_msg_list_id: number | undefined;
export const pm_recipient = {
async set(page: Page, recipient: string): Promise<void> {
// Without using the delay option here there seems to be
// a flake where the typeahead doesn't show up.
// The flake seems to be due to some method that triggers focus on
// compose textarea, which causes the typeahead to not show up.
// Add a delay before typing.
await timersPromises.setTimeout(100);
await page.type("#private_message_recipient", recipient);
// PM typeaheads always have an image. This ensures we are waiting for the right typeahead to appear.
const entry = await page.waitForSelector(".typeahead .active a .typeahead-image", {
visible: false,
});
// log entry in puppeteer logs
console.log(await entry!.evaluate((el) => el.textContent));
await entry!.click();
},
async expect(page: Page, expected: string): Promise<void> {
const actual_recipients = await page.evaluate(() =>
zulip_test.private_message_recipient_emails(),
);
assert.equal(actual_recipients, expected);
},
};
export const fullname = {
cordelia: "Cordelia, Lear's daughter",
othello: "Othello, the Moor of Venice",
hamlet: "King Hamlet",
};
export const window_size = {
width: 1400,
height: 1024,
};
export async function ensure_browser(): Promise<Browser> {
browser ??= await puppeteer.launch({
args: [
`--window-size=${window_size.width},${window_size.height}`,
"--no-sandbox",
"--disable-setuid-sandbox",
],
// TODO: Change defaultViewport to 1280x1024 when puppeteer fixes the window size issue with firefox.
// Here is link to the issue that is tracking the above problem https://github.com/puppeteer/puppeteer/issues/6442.
defaultViewport: null,
headless: true,
});
return browser;
}
export async function get_page(): Promise<Page> {
const browser = await ensure_browser();
const page = await browser.newPage();
return page;
}
export async function screenshot(page: Page, name: string | null = null): Promise<void> {
if (name === null) {
name = `${screenshot_id}`;
screenshot_id += 1;
}
await page.screenshot({
path: `${path.join(puppeteer_dir, name)}.png`,
});
}
export async function page_url_with_fragment(page: Page): Promise<string> {
// `page.url()` does not include the url fragment when running
// Puppeteer with Firefox: https://github.com/puppeteer/puppeteer/issues/6787.
//
// This function hacks around that issue; once it's fixed in
// puppeteer upstream, we can delete this function and return
// its callers to using `page.url()`
return await page.evaluate(() => window.location.href);
}
// This function will clear the existing value of the element and
// replace it with the text.
export async function clear_and_type(page: Page, selector: string, text: string): Promise<void> {
// Select all text currently in the element.
await page.click(selector, {count: 3});
await page.keyboard.press("Delete");
await page.type(selector, text);
}
/**
* This function takes a params object whose fields
* are referenced by name attribute of an input field and
* the input as a key.
*
* For example to fill:
* <form id="#demo">
* <input type="text" name="username">
* <input type="checkbox" name="terms">
* </form>
*
* You can call:
* common.fill_form(page, '#demo', {
* username: 'Iago',
* terms: true
* });
*/
export async function fill_form(
page: Page,
form_selector: string,
params: Record<string, boolean | string>,
): Promise<void> {
async function is_dropdown(page: Page, name: string): Promise<boolean> {
return (await page.$(`select[name="${CSS.escape(name)}"]`)) !== null;
}
for (const [name, value] of Object.entries(params)) {
if (typeof value === "boolean") {
await page.$eval(
`${form_selector} input[name="${CSS.escape(name)}"]`,
(el, value) => {
if (el.checked !== value) {
el.click();
}
},
value,
);
} else if (await is_dropdown(page, name)) {
if (typeof value !== "string") {
throw new TypeError(`Expected string for ${name}`);
}
await page.select(`${form_selector} select[name="${CSS.escape(name)}"]`, value);
} else {
await clear_and_type(page, `${form_selector} [name="${CSS.escape(name)}"]`, value);
}
}
}
export async function check_form_contents(
page: Page,
form_selector: string,
params: Record<string, boolean | string>,
): Promise<void> {
for (const [name, expected_value] of Object.entries(params)) {
if (typeof expected_value === "boolean") {
assert.equal(
await page.$eval(
`${form_selector} input[name="${CSS.escape(name)}"]`,
(el) => el.checked,
),
expected_value,
"Form content is not as expected.",
);
} else {
assert.equal(
await page.$eval(`${form_selector} [name="${CSS.escape(name)}"]`, (el) => {
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
throw new TypeError("Expected <input> or <textarea>");
}
return el.value;
}),
expected_value,
"Form content is not as expected.",
);
}
}
}
export async function get_element_text(element: ElementHandle): Promise<string> {
const text = await (await element.getProperty("innerText")).jsonValue();
assert.ok(typeof text === "string");
return text;
}
export async function get_text_from_selector(page: Page, selector: string): Promise<string> {
const elements = await page.$$(selector);
const texts = await Promise.all(elements.map(async (element) => get_element_text(element)));
return texts.join("").trim();
}
export async function check_compose_state(
page: Page,
params: {stream_name?: string; topic?: string; content: string},
): Promise<void> {
const form_params: Record<string, string> = {content: params.content};
if (params.stream_name) {
assert.equal(
await get_text_from_selector(
page,
"#compose_select_recipient_widget .dropdown_widget_value",
),
params.stream_name,
);
}
if (params.topic) {
form_params["stream_message_recipient_topic"] = params.topic;
}
await check_form_contents(page, "form#send_message_form", form_params);
}
export function has_class_x(class_name: string): string {
return `contains(concat(" ", @class, " "), " ${class_name} ")`;
}
export async function get_stream_id(page: Page, stream_name: string): Promise<number | undefined> {
return await page.evaluate(
(stream_name: string) => zulip_test.get_stream_id(stream_name),
stream_name,
);
}
export async function get_user_id_from_name(page: Page, name: string): Promise<number | undefined> {
return await page.evaluate((name: string) => zulip_test.get_user_id_from_name(name), name);
}
export async function get_internal_email_from_name(
page: Page,
name: string,
): Promise<string | undefined> {
return await page.evaluate((fullname: string) => {
const user_id = zulip_test.get_user_id_from_name(fullname);
return user_id === undefined ? undefined : zulip_test.get_person_by_user_id(user_id).email;
}, name);
}
export async function log_in(
page: Page,
credentials: {username: string; password: string} = test_credentials.default_user,
): Promise<void> {
console.log("Logging in");
await page.goto(realm_url + "login/");
assert.equal(realm_url + "login/", page.url());
// fill login form
const params = {
username: credentials.username,
password: credentials.password,
};
await fill_form(page, "form#login_form", params);
await page.$eval("form#login_form", (form) => {
form.submit();
});
await page.waitForSelector("#inbox-main", {visible: true});
}
export async function log_out(page: Page): Promise<void> {
await page.goto(realm_url);
const menu_selector = "#personal-menu";
const logout_selector = ".personal-menu-actions a.logout_button";
console.log("Logging out");
await page.waitForSelector(menu_selector, {visible: true});
await page.click(menu_selector);
await page.waitForSelector(logout_selector);
await Promise.all([page.waitForNavigation(), page.click(logout_selector)]);
// Wait for a email input in login page so we know login
// page is loaded. Then check that we are at the login url.
await page.waitForSelector('input[name="username"]');
assert.ok(page.url().includes("/login/"));
}
export function set_realm_url(new_realm_url: string): void {
realm_url = new_realm_url;
}
export async function ensure_enter_does_not_send(page: Page): Promise<void> {
// NOTE: Caller should ensure that the compose box is already open.
await page.click("#send_later");
await page.waitForSelector("#send_later_popover");
const enter_sends = await page.$eval(
".enter_sends_choice input[value='true']",
(el) => el.checked,
);
if (enter_sends) {
const enter_sends_false_selector = ".enter_sends_choice input[value='false']";
await page.waitForSelector(enter_sends_false_selector);
await page.click(enter_sends_false_selector);
}
}
export async function assert_compose_box_content(
page: Page,
expected_value: string,
): Promise<void> {
const compose_box_element = await page.waitForSelector("textarea#compose-textarea");
assert.ok(compose_box_element !== null);
const compose_box_content = await page.evaluate(
(element) => element.value,
compose_box_element,
);
assert.equal(
compose_box_content,
expected_value,
`Compose box content did not match with the expected value '{${expected_value}}'`,
);
}
export async function wait_for_fully_processed_message(page: Page, content: string): Promise<void> {
// Wait in parallel for the message list scroll animation, which
// interferes with Puppeteer accurately clicking on messages.
const scroll_delay = timersPromises.setTimeout(400);
await page.waitForFunction(
(content: string) => {
/*
The tricky part about making sure that
a message has actually been fully processed
is that we'll "locally echo" the message
first on the client. Until the server
actually acks the message, the message will
have a temporary id and will not have all
the normal message controls.
For the Puppeteer tests, we want to avoid all
the edge cases with locally echoed messages.
In order to make sure a message is processed,
we use internals to determine the following:
- has message_list even been updated with
the message with out content?
- has the locally_echoed flag been cleared?
But for the final steps we look at the
actual DOM (via JQuery):
- is it visible?
- does it look to have been
re-rendered based on server info?
*/
const last_msg = zulip_test.current_msg_list?.last();
if (last_msg === undefined) {
return false;
}
if (last_msg.raw_content !== content) {
return false;
}
if (last_msg.locally_echoed) {
return false;
}
const $row = zulip_test.last_visible_row();
if (zulip_test.row_id($row) !== last_msg.id) {
return false;
}
/*
Make sure the message is completely
re-rendered from its original "local echo"
version by looking for the star icon. We
don't add the star icon until the server
responds.
*/
return $row.find(".star").length === 1;
},
{},
content,
);
await scroll_delay;
}
export async function select_stream_in_compose_via_dropdown(
page: Page,
stream_name: string,
): Promise<void> {
console.log(`Clicking on 'compose_select_recipient_widget' to select ${stream_name}`);
const menu_visible = (await page.$(".dropdown-list-container")) !== null;
if (!menu_visible) {
await page.waitForSelector("#compose_select_recipient_widget", {visible: true});
await page.click("#compose_select_recipient_widget");
await page.waitForSelector(".dropdown-list-container .list-item", {
visible: true,
});
}
const stream_to_select = `.dropdown-list-container .list-item[data-name="${stream_name}"]`;
await page.waitForSelector(stream_to_select, {visible: true});
await page.click(stream_to_select);
await page.waitForSelector(".dropdown-list-container", {hidden: true});
}
// Wait for any previous send to finish, then send a message.
export async function send_message(
page: Page,
type: "stream" | "private",
params: Message,
wait_for_narrow_change = true,
): Promise<void> {
// Compose box content should be empty before sending the message.
await assert_compose_box_content(page, "");
if (type === "stream") {
await page.keyboard.press("KeyC");
} else if (type === "private") {
await page.keyboard.press("KeyX");
const recipients = params.recipient!.split(", ");
for (const recipient of recipients) {
await pm_recipient.set(page, recipient);
}
delete params.recipient;
} else {
assert.fail("`send_message` got invalid message type");
}
if (params.stream_name) {
await select_stream_in_compose_via_dropdown(page, params.stream_name);
delete params.stream_name;
}
if (params.topic) {
params["stream_message_recipient_topic"] = params.topic;
delete params.topic;
}
await fill_form(page, 'form[action^="/json/messages"]', params);
await assert_compose_box_content(page, params.content);
await ensure_enter_does_not_send(page);
await page.waitForSelector("#compose-send-button", {visible: true});
await page.click("#compose-send-button");
if (wait_for_narrow_change) {
// After the message is sent, wait for the narrow
// to change to the message recipient.
await get_current_msg_list_id(page, true);
}
// Sending should clear compose box content.
await assert_compose_box_content(page, "");
await wait_for_fully_processed_message(page, params.content);
// Close the compose box after sending the message.
await page.evaluate(() => {
zulip_test.cancel_compose();
});
// Make sure the compose box is closed.
await page.waitForSelector("#compose-textarea", {hidden: true});
}
export async function send_multiple_messages(page: Page, msgs: Message[]): Promise<void> {
let last_msg_stream;
let last_msg_topic;
let last_msg_recipient;
for (const msg of msgs) {
const msg_type = msg.stream_name !== undefined ? "stream" : "private";
// Check if `msg` and `last_msg` are in the same narrow.
let wait_for_narrow_change = true;
if (
msg.stream_name === last_msg_stream &&
msg.topic === last_msg_topic &&
msg.recipient === last_msg_recipient
) {
wait_for_narrow_change = false;
}
last_msg_stream = msg.stream_name;
last_msg_topic = msg.topic;
last_msg_recipient = msg.recipient;
await send_message(page, msg_type, msg, wait_for_narrow_change);
}
}
/**
* This method returns a array, which is formatted as:
* [
* ['stream > topic', ['message 1', 'message 2']],
* ['You and Cordelia, Lear's daughter', ['message 1', 'message 2']]
* ]
*
* The messages are sorted chronologically.
*/
export async function get_rendered_messages(
page: Page,
message_list_id: number,
): Promise<[string, string[]][]> {
const recipient_rows = await page.$$(
`.message-list[data-message-list-id='${message_list_id}'] .recipient_row`,
);
return Promise.all(
recipient_rows.map(async (element): Promise<[string, string[]]> => {
const stream_label = await element.$(".stream_label");
const stream_name = (await get_element_text(stream_label!)).trim();
const topic_label = await element.$(".stream_topic a");
const topic_name =
topic_label === null ? "" : (await get_element_text(topic_label)).trim();
let key = stream_name;
if (topic_name !== "") {
// If topic_name is '', then this is direct messages, so only
// append > topic_name if we are not in 1:1 or group direct
// messages.
key = `${stream_name} > ${topic_name}`;
}
const messages = await Promise.all(
(await element.$$(".message_row .message_content")).map(async (message_row) =>
(await get_element_text(message_row)).trim(),
),
);
return [key, messages];
}),
);
}
// This method takes in page, table to fetch the messages
// from, and expected messages. The format of expected
// message is { "stream > topic": [messages] }.
// The method will only check that all the messages in the
// messages array passed exist in the order they are passed.
export async function check_messages_sent(
page: Page,
message_list_id: number,
messages: [string, string[]][],
): Promise<void> {
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
const rendered_messages = await get_rendered_messages(page, message_list_id);
// We only check the last n messages because if we run
// the test with --interactive there will be duplicates.
const last_n_messages = rendered_messages.slice(-messages.length);
assert.deepStrictEqual(last_n_messages, messages);
}
export async function open_streams_modal(page: Page): Promise<void> {
const all_streams_selector = "#subscribe-to-more-streams";
await page.waitForSelector(all_streams_selector, {visible: true});
await page.click(all_streams_selector);
await page.waitForSelector("#subscription_overlay", {visible: true});
const url = await page_url_with_fragment(page);
assert.ok(url.includes("#channels/available"));
}
export async function open_personal_menu(page: Page): Promise<void> {
const menu_selector = "#personal-menu";
await page.waitForSelector(menu_selector, {visible: true});
await page.click(menu_selector);
}
export async function manage_organization(page: Page): Promise<void> {
const menu_selector = "#settings-dropdown";
await page.waitForSelector(menu_selector, {visible: true});
await page.click(menu_selector);
const organization_settings = '.link-item a[href="#organization"]';
await page.waitForSelector(organization_settings, {visible: true});
await page.click(organization_settings);
await page.waitForSelector("#settings_overlay_container.show", {visible: true});
const url = await page_url_with_fragment(page);
assert.match(url, /^http:\/\/[^/]+\/#organization/, "Unexpected organization settings URL");
const organization_settings_data_section = "li[data-section='organization-settings']";
await page.click(organization_settings_data_section);
}
export async function select_item_via_typeahead(
page: Page,
field_selector: string,
str: string,
item: string,
): Promise<void> {
console.log(`Looking in ${field_selector} to select ${str}, ${item}`);
await clear_and_type(page, field_selector, str);
const entry = await page.waitForSelector(
`xpath///*[${has_class_x("typeahead")}]//li[contains(normalize-space(), "${item}")]//a`,
{visible: true},
);
assert.ok(entry);
await entry.hover();
await entry.click();
}
export async function wait_for_modal_to_close(page: Page): Promise<void> {
// This function will ensure that the mouse events are enabled for the background for further tests.
await page.waitForFunction(
() => document.querySelector(".overlay.show")?.getAttribute("style") === null,
);
}
export async function wait_for_micromodal_to_open(page: Page): Promise<void> {
// We manually add the `modal--open` class to the modal after the modal animation completes.
await page.waitForFunction(() => document.querySelector(".modal--open") !== null);
}
export async function wait_for_micromodal_to_close(page: Page): Promise<void> {
// This function will ensure that the mouse events are enabled for the background for further tests.
await page.waitForFunction(() => document.querySelector(".modal--open") === null);
}
export async function run_test(test_function: (page: Page) => Promise<void>): Promise<void> {
// Pass a page instance to test so we can take
// a screenshot of it when the test fails.
const browser = await ensure_browser();
const page = await get_page();
// Used to keep console messages in order after async source mapping
let console_ready = Promise.resolve();
page.on("console", (message: ConsoleMessage) => {
const context = async ({
url,
lineNumber,
columnNumber,
}: ConsoleMessageLocation): Promise<string> => {
let frame = new StackFrame({
...(url !== undefined && {fileName: url}),
...(lineNumber !== undefined && {lineNumber: lineNumber + 1}),
...(columnNumber !== undefined && {columnNumber: columnNumber + 1}),
});
try {
frame = await gps.getMappedLocation(frame);
} catch {
// Ignore source mapping errors
}
if (frame.lineNumber === undefined || frame.columnNumber === undefined) {
return String(frame.fileName);
}
return `${String(frame.fileName)}:${frame.lineNumber}:${frame.columnNumber}`;
};
const console_ready1 = console_ready;
console_ready = (async () => {
let output = `${await context(
message.location(),
)}: ${message.type()}: ${message.text()}`;
if (message.type() === "trace") {
for (const frame of message.stackTrace()) {
output += `\n at ${await context(frame)}`;
}
}
await console_ready1;
console.log(output);
})();
});
let page_errored = false;
page.on("pageerror", (error: unknown) => {
page_errored = true;
const console_ready1 = console_ready;
console_ready = (async () => {
let message;
if (error instanceof Error) {
const frames = await Promise.all(
ErrorStackParser.parse(error).map(async (frame) => {
try {
frame = await gps.getMappedLocation(frame);
} catch {
// Ignore source mapping errors
}
return `\n at ${String(frame.functionName)} (${String(
frame.fileName,
)}:${String(frame.lineNumber)}:${String(frame.columnNumber)})`;
}),
);
message = error.toString() + frames.join("");
} else {
message = String(error);
}
await console_ready1;
console.error("Page error:", message);
})();
const console_ready2 = console_ready;
console_ready = (async () => {
try {
// Take a screenshot, and increment the screenshot_id.
await screenshot(page, `failure-${screenshot_id}`);
screenshot_id += 1;
} finally {
await console_ready2;
console.log("Closing page to stop the test...");
await page.close();
}
})();
});
try {
await test_function(page);
await log_out(page);
if (page_errored) {
throw new Error("Page threw an error");
}
} catch (error: unknown) {
if (!page_errored) {
// Take a screenshot, and increment the screenshot_id.
await screenshot(page, `failure-${screenshot_id}`);
screenshot_id += 1;
}
throw error;
} finally {
await console_ready;
await browser.close();
}
}
export async function get_current_msg_list_id(
page: Page,
wait_for_change = false,
): Promise<number> {
if (wait_for_change) {
// Wait for the current_msg_list to change if the in the middle of switching narrows.
// Also works as a way to verify that the current message list did change.
// NOTE: This only checks if the current message list id changed from the last call to this function,
// so, make sure to have a call to this function before changing to the narrow that you want to check.
await page.waitForFunction(
(last_current_msg_list_id) => {
const current_msg_list = zulip_test.current_msg_list;
return (
current_msg_list !== undefined &&
current_msg_list.id !== last_current_msg_list_id
);
},
{},
last_current_msg_list_id,
);
}
last_current_msg_list_id = await page.evaluate(() => zulip_test.current_msg_list?.id);
assert.ok(last_current_msg_list_id !== undefined);
return last_current_msg_list_id;
}

View File

@@ -0,0 +1,268 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
const stream_name = "Verona";
const topic_name = "near-read-test";
const realm_url = "http://zulip.zulipdev.com:9981/";
async function get_stream_id(page: Page): Promise<number> {
const stream_id = await common.get_stream_id(page, stream_name);
assert.ok(stream_id !== undefined);
return stream_id;
}
async function navigate_to_settings_preferences(page: Page): Promise<void> {
await common.open_personal_menu(page);
await page.waitForSelector("#personal-menu-dropdown a[href^='#settings']", {visible: true});
await page.click("#personal-menu-dropdown a[href^='#settings']");
await page.waitForSelector("#settings_overlay_container.show", {visible: true});
await page.waitForSelector('[data-section="preferences"]', {visible: true});
await page.click('[data-section="preferences"]');
await page.waitForSelector("#user_web_mark_read_on_scroll_policy", {visible: true});
}
async function change_mark_read_policy(page: Page, value: string): Promise<void> {
await navigate_to_settings_preferences(page);
await page.select("#user_web_mark_read_on_scroll_policy", value);
await page.waitForSelector("#user-preferences .general-settings-status", {visible: true});
await page.click("#settings_page .content-wrapper .exit");
await page.waitForSelector("#settings_overlay_container", {hidden: true});
}
// Test 1: A /near/ narrow is treated as a conversation view.
async function test_near_narrow_is_conversation_view(page: Page): Promise<void> {
console.log("Testing that near narrow is treated as a conversation view");
await common.send_message(page, "stream", {
stream_name,
topic: topic_name,
content: "message for near narrow test",
});
const stream_id = await get_stream_id(page);
const msg_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
assert.ok(msg_id !== undefined, "Expected a message to be sent and visible");
// Navigate away from the topic, then come back via /near/ URL.
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
await common.get_current_msg_list_id(page, true);
await page.goto(
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${topic_name}/near/${msg_id}`,
);
await common.get_current_msg_list_id(page, true);
const is_conversation_view = await page.evaluate(() =>
zulip_test.current_msg_list?.data.filter.is_conversation_view(),
);
assert.ok(is_conversation_view, "Near narrow should be treated as a conversation view");
const is_conversation_view_with_near = await page.evaluate(() =>
zulip_test.current_msg_list?.data.filter.is_conversation_view_with_near(),
);
assert.ok(
is_conversation_view_with_near,
"Near narrow should still identify as is_conversation_view_with_near",
);
}
// Test 2: The reading gate clears immediately when all messages in the
// near narrow are already read, enabling normal mark-as-read behavior.
async function test_near_narrow_reading_gate_clears(page: Page): Promise<void> {
console.log("Testing that reading gate clears when no unread messages in near view");
// We are on the near narrow from test 1; get the last message ID.
const stream_id = await get_stream_id(page);
const msg_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
assert.ok(msg_id !== undefined);
// Navigate away, then return via /near/ URL. All messages were
// already marked as read in test 1 when we first visited this topic.
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
await common.get_current_msg_list_id(page, true);
await page.goto(
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${topic_name}/near/${msg_id}`,
);
await common.get_current_msg_list_id(page, true);
// The not_found case fires immediately (no unreads), clearing the gate
// and resuming reading via resume_reading().
await page.waitForFunction(
() => zulip_test.current_msg_list?.near_view_reading_gate_pending === false,
{timeout: 5000},
);
const reading_prevented = await page.evaluate(
() => zulip_test.current_msg_list?.reading_prevented,
);
assert.equal(
reading_prevented,
false,
"reading_prevented should be false after gate clears with no unreads",
);
}
// Test 3: With conversation_only policy, near views don't show the
// "not a conversation view" banner because they're treated as conversation views.
async function test_near_narrow_no_conversation_only_banner(page: Page): Promise<void> {
console.log("Testing banner behavior in near narrow with conversation_only policy");
// We are on the near narrow from test 2.
const stream_id = await get_stream_id(page);
const msg_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
assert.ok(msg_id !== undefined);
// Change policy to "Only in conversation views".
await change_mark_read_policy(page, "2");
// Navigate to a channel-only narrow (not a conversation view).
// update_unread_banner() always sets the banner HTML on narrow change, so
// verify the banner text contains "conversation" without requiring the banner
// to be visible (which requires an unread message + scroll to trigger).
await page.goto(`${realm_url}#narrow/channel/${stream_id}-${stream_name}`);
await common.get_current_msg_list_id(page, true);
await page.waitForFunction(
() =>
(
document.querySelector("#mark_as_read_turned_off_content")?.textContent ?? ""
).includes("conversation"),
{timeout: 5000},
);
const channel_banner_text = await page.$eval(
"#mark_as_read_turned_off_content",
(el) => el.textContent ?? "",
);
assert.ok(
channel_banner_text.includes("conversation"),
"Channel narrow should use 'not a conversation view' banner template",
);
// Navigate to a near narrow. It should not show the "not a
// conversation view" banner since near views are now conversation views.
await page.goto(
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${topic_name}/near/${msg_id}`,
);
await common.get_current_msg_list_id(page, true);
// Wait for the reading gate to clear (all messages are read, so
// maybe_resume_reading_for_near_view fires the not_found path).
await page.waitForFunction(
() => zulip_test.current_msg_list?.near_view_reading_gate_pending === false,
{timeout: 5000},
);
const near_banner_text = await page.$eval(
"#mark_as_read_turned_off_content",
(el) => el.textContent ?? "",
);
assert.ok(
!near_banner_text.includes("conversation"),
"Near narrow should not show 'not a conversation view' banner",
);
// Reset the policy to "Always".
await change_mark_read_policy(page, "1");
}
// Test 4: The reading gate only clears once the first unread message
// has scrolled into the visible area of the viewport.
async function test_near_narrow_gate_requires_scroll(page: Page): Promise<void> {
console.log("Testing that gate requires first unread to be visible before marking as read");
const gate_topic = "near-gate-test";
const stream_id = await get_stream_id(page);
// Send 15 messages so that, when anchored at the last one, the first
// message is well above the viewport and the gate cannot clear until
// the user scrolls back to the top.
const msgs = Array.from({length: 15}, (_, i) => ({
stream_name,
topic: gate_topic,
content: `gate test message ${i + 1}`,
}));
await common.send_multiple_messages(page, msgs);
const msg_last_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
assert.ok(msg_last_id !== undefined);
// Navigate to the combined feed (all sent messages are now marked as read).
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
await common.get_current_msg_list_id(page, true);
// Mark all messages in the topic as unread so the gate has something to gate on.
await page.evaluate(
(sid: number, topic: string) => {
zulip_test.mark_topic_as_unread(sid, topic);
},
stream_id,
gate_topic,
);
// Wait for the client's unread state to reflect the server's response.
await page.waitForFunction(
(sid: number, topic: string) => zulip_test.num_unread_for_topic(sid, topic) > 0,
{timeout: 10000},
stream_id,
gate_topic,
);
// Navigate via /near/ anchored at the last message. The first unread
// (message 1) is above the viewport, so the gate should stay pending.
await page.goto(
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${gate_topic}/near/${msg_last_id}`,
);
await common.get_current_msg_list_id(page, true);
// Gate must still be pending: the first unread is above the visible area.
const gate_pending = await page.evaluate(
() => zulip_test.current_msg_list?.near_view_reading_gate_pending,
);
assert.ok(gate_pending, "Gate should be pending when first unread is above the viewport");
let reading_prevented = await page.evaluate(
() => zulip_test.current_msg_list?.reading_prevented,
);
assert.equal(
reading_prevented,
true,
"reading_prevented should be false after scrolling first unread into view",
);
// Scroll to the top so the first unread enters the visible area, then
// directly call maybe_resume_reading_for_near_view to check whether
// the gate clears. We call it directly (rather than relying on the
// scroll handler) because the test scroll and the initial render's
// anchor scroll share a debounce timer, causing scroll_finished() to
// fire once with update_selection_on_next_scroll=false and skip the
// gate check. This matches how render_message_list_with_selected_message
// calls the function directly during the initial render.
await page.evaluate(() => {
document.documentElement.scrollTop = 0;
zulip_test.current_msg_list?.maybe_resume_reading_for_near_view();
});
// The gate should now clear since the first unread is visible.
await page.waitForFunction(
() => zulip_test.current_msg_list?.near_view_reading_gate_pending === false,
{timeout: 5000},
);
reading_prevented = await page.evaluate(() => zulip_test.current_msg_list?.reading_prevented);
assert.equal(
reading_prevented,
false,
"reading_prevented should be false after scrolling first unread into view",
);
}
async function mark_messages_read_near_test(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
await common.get_current_msg_list_id(page, true);
await test_near_narrow_is_conversation_view(page);
await test_near_narrow_reading_gate_clears(page);
await test_near_narrow_no_conversation_only_banner(page);
await test_near_narrow_gate_requires_scroll(page);
}
await common.run_test(mark_messages_read_near_test);

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function test_mention(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
let message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
await page.keyboard.press("KeyC");
await page.waitForSelector("#compose", {visible: true});
await common.select_stream_in_compose_via_dropdown(page, "Verona");
await common.fill_form(page, 'form[action^="/json/messages"]', {
stream_message_recipient_topic: "Test mention all",
});
await common.select_item_via_typeahead(page, "#compose-textarea", "@**all", "all");
await common.ensure_enter_does_not_send(page);
console.log("Checking for all everyone warning");
const stream_size = await page.evaluate(() =>
zulip_test.get_subscriber_count(zulip_test.get_sub("Verona")!.stream_id),
);
const threshold = await page.evaluate(() => {
zulip_test.set_wildcard_mention_threshold(5);
return zulip_test.wildcard_mention_threshold;
});
assert.ok(stream_size > threshold);
await page.click("#compose-send-button");
await page.waitForSelector(
"#compose_banners .wildcard_warning .main-view-banner-action-button",
{visible: true},
);
await page.click("#compose_banners .wildcard_warning .main-view-banner-action-button");
await page.waitForSelector(".wildcard_warning", {hidden: true});
message_list_id = await common.get_current_msg_list_id(page, true);
await common.check_messages_sent(page, message_list_id, [
["Verona > Test mention all", ["@all"]],
]);
}
await common.run_test(test_mention);

View File

@@ -0,0 +1,538 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function get_stream_li(page: Page, stream_name: string): Promise<string> {
const stream_id = await common.get_stream_id(page, stream_name);
assert.ok(stream_id !== undefined);
return `#stream_filters .narrow-filter[data-stream-id="${CSS.escape(stream_id.toString())}"]`;
}
async function expect_home(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
// Assert that there is only one message list.
assert.equal((await page.$$(".message-list")).length, 1);
assert.strictEqual(await page.title(), "Combined feed - Zulip Dev - Zulip");
await common.check_messages_sent(page, message_list_id, [
["Verona > test", ["verona test a", "verona test b"]],
["Verona > other topic", ["verona other topic c"]],
["Denmark > test", ["denmark message"]],
[
"You and Cordelia, Lear's daughter, King Hamlet",
["group direct message a", "group direct message b"],
],
["You and Cordelia, Lear's daughter", ["direct message c"]],
["Verona > test", ["verona test d"]],
["You and Cordelia, Lear's daughter, King Hamlet", ["group direct message d"]],
["You and Cordelia, Lear's daughter", ["direct message e"]],
]);
}
async function expect_verona_stream_top_topic(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
["Verona > test", ["verona test a", "verona test b", "verona test d"]],
]);
assert.strictEqual(await page.title(), "#Verona > test - Zulip Dev - Zulip");
}
async function expect_verona_stream(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
["Verona > test", ["verona test a", "verona test b"]],
["Verona > other topic", ["verona other topic c"]],
["Verona > test", ["verona test d"]],
]);
assert.strictEqual(await page.title(), "#Verona - Zulip Dev - Zulip");
}
async function expect_verona_stream_test_topic(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
["Verona > test", ["verona test a", "verona test b", "verona test d"]],
]);
assert.strictEqual(
await common.get_text_from_selector(page, "#new_conversation_button"),
"Start new conversation",
);
}
async function expect_verona_other_topic(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
["Verona > other topic", ["verona other topic c"]],
]);
}
async function expect_test_topic(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
["Verona > test", ["verona test a", "verona test b"]],
["Denmark > test", ["denmark message"]],
["Verona > test", ["verona test d"]],
]);
}
async function expect_group_direct_messages(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
[
"You and Cordelia, Lear's daughter, King Hamlet",
["group direct message a", "group direct message b", "group direct message d"],
],
]);
assert.strictEqual(
await page.title(),
"Cordelia, Lear's daughter, King Hamlet - Zulip Dev - Zulip",
);
}
async function expect_cordelia_direct_messages(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
["You and Cordelia, Lear's daughter", ["direct message c", "direct message e"]],
]);
}
async function un_narrow(page: Page): Promise<void> {
if ((await (await page.$(".message_comp"))!.boundingBox())?.height) {
await page.keyboard.press("Escape");
}
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
}
async function un_narrow_by_clicking_org_icon(page: Page): Promise<void> {
await page.click(".brand");
}
async function expect_recent_view(page: Page): Promise<void> {
await page.waitForSelector("#recent_view_table", {visible: true});
assert.strictEqual(await page.title(), "Recent conversations - Zulip Dev - Zulip");
}
async function test_navigations_from_home(page: Page): Promise<void> {
return; // No idea why this is broken.
console.log("Narrowing by clicking stream");
await page.click(`.focused-message-list [title='Narrow to stream "Verona"']`);
await expect_verona_stream(page);
assert.strictEqual(await page.title(), "#Verona - Zulip Dev - Zulip");
await un_narrow(page);
await expect_home(page);
console.log("Narrowing by clicking topic");
await page.click(`.focused-message-list [title='Narrow to stream "Verona", topic "test"']`);
await expect_verona_stream_test_topic(page);
await un_narrow(page);
await expect_home(page);
return; // TODO: rest of this test seems nondeterministically broken
console.log("Narrowing by clicking group personal header");
await page.click(
`.focused-message-list [title="Narrow to your direct messages with Cordelia, Lear's daughter, King Hamlet"]`,
);
await expect_group_direct_messages(page);
await un_narrow(page);
await expect_home(page);
await page.click(
`.focused-message-list [title="Narrow to your direct messages with Cordelia, Lear's daughter, King Hamlet"]`,
);
await un_narrow_by_clicking_org_icon(page);
await expect_recent_view(page);
}
async function search_and_check(
page: Page,
search_str: string,
item_to_select: string,
check: (page: Page) => Promise<void>,
expected_narrow_title: string,
): Promise<void> {
await page.click(".search_icon");
await page.waitForSelector(".navbar-search.expanded", {visible: true});
await common.select_item_via_typeahead(page, "#search_query", search_str, item_to_select);
// Enter to trigger search
await page.keyboard.press("Enter");
await check(page);
assert.strictEqual(await page.title(), expected_narrow_title);
await un_narrow(page);
await expect_home(page);
}
async function search_silent_user(page: Page, str: string, item: string): Promise<void> {
await page.click(".search_icon");
await page.waitForSelector(".navbar-search.expanded", {visible: true});
await common.select_item_via_typeahead(page, "#search_query", str, item);
// Enter to trigger search
await page.keyboard.press("Enter");
await page.waitForSelector(".empty_feed_notice", {visible: true});
const expect_message = "You haven't received any messages sent by Email Gateway yet.";
assert.strictEqual(
await common.get_text_from_selector(page, ".empty-feed-notice-title"),
expect_message,
);
await common.get_current_msg_list_id(page, true);
await un_narrow(page);
await expect_home(page);
}
async function search_tests(page: Page): Promise<void> {
await search_and_check(
page,
"Verona",
"channel: Verona",
expect_verona_stream,
"#Verona - Zulip Dev - Zulip",
);
await search_and_check(
page,
"Cordelia",
"dm:",
expect_cordelia_direct_messages,
"Cordelia, Lear's daughter - Zulip Dev - Zulip",
);
await search_and_check(
page,
"stream:Verona",
"",
expect_verona_stream,
"#Verona - Zulip Dev - Zulip",
);
await search_and_check(
page,
"stream:Verona topic:test",
"",
expect_verona_stream_test_topic,
"#Verona > test - Zulip Dev - Zulip",
);
await search_and_check(
page,
"stream:Verona topic:other+topic",
"",
expect_verona_other_topic,
"#Verona > other topic - Zulip Dev - Zulip",
);
await search_and_check(
page,
"topic:test",
"",
expect_test_topic,
"Search results - Zulip Dev - Zulip",
);
await search_silent_user(page, "sender:emailgateway@zulip.com", "");
}
async function expect_all_direct_messages(page: Page): Promise<void> {
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
await common.check_messages_sent(page, message_list_id, [
[
"You and Cordelia, Lear's daughter, King Hamlet",
["group direct message a", "group direct message b"],
],
["You and Cordelia, Lear's daughter", ["direct message c"]],
["You and Cordelia, Lear's daughter, King Hamlet", ["group direct message d"]],
["You and Cordelia, Lear's daughter", ["direct message e"]],
]);
assert.strictEqual(
await common.get_text_from_selector(page, "#new_conversation_button"),
"Start new conversation",
);
assert.strictEqual(await page.title(), "Direct message feed - Zulip Dev - Zulip");
}
async function test_narrow_by_clicking_the_left_sidebar(page: Page): Promise<void> {
console.log("Narrowing with left sidebar");
await page.click((await get_stream_li(page, "Verona")) + " .stream-name");
await expect_verona_stream_top_topic(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages a");
await expect_home(page);
const all_private_messages_icon = ".show-all-direct-messages";
await page.waitForSelector(all_private_messages_icon, {visible: true});
await page.click(all_private_messages_icon);
await expect_all_direct_messages(page);
await un_narrow(page);
await expect_home(page);
}
async function arrow(page: Page, direction: "Up" | "Down"): Promise<void> {
await page.keyboard.press(({Up: "ArrowUp", Down: "ArrowDown"} as const)[direction]);
}
async function test_search_venice(page: Page): Promise<void> {
await common.clear_and_type(page, ".left-sidebar-search-input", "vEnI"); // Must be case insensitive.
await page.waitForSelector(await get_stream_li(page, "Denmark"), {hidden: true});
await page.waitForSelector(await get_stream_li(page, "Verona"), {hidden: true});
await arrow(page, "Down");
await page.waitForSelector((await get_stream_li(page, "Venice")) + " .highlighted_row", {
visible: true,
});
// Clearing list gives back all the streams in the list
await common.clear_and_type(page, ".left-sidebar-search-input", "");
await page.waitForSelector(await get_stream_li(page, "Denmark"), {visible: true});
await page.waitForSelector(await get_stream_li(page, "Venice"), {visible: true});
await page.waitForSelector(await get_stream_li(page, "Verona"), {visible: true});
}
async function test_stream_search_filters_stream_list(page: Page): Promise<void> {
console.log("Filter streams using left side bar");
await page.waitForSelector(".left-sidebar-search-section");
// assert streams exist by waiting till they're visible
await page.waitForSelector(await get_stream_li(page, "Denmark"), {visible: true});
await page.waitForSelector(await get_stream_li(page, "Venice"), {visible: true});
await page.waitForSelector(await get_stream_li(page, "Verona"), {visible: true});
// Enter the search box and test highlighted suggestion
await page.click(".left-sidebar-search-input");
// Selection is not highlighted until user wants to move the cursor.
await page.waitForSelector(".top_left_inbox.top_left_row.highlighted_row", {hidden: true});
await arrow(page, "Down");
await page.waitForSelector(".top_left_inbox.top_left_row.highlighted_row", {visible: true});
await page.waitForSelector((await get_stream_li(page, "Verona")) + " .highlighted_row", {
hidden: true,
});
// Navigate through suggestions using arrow keys
// Reach core team
for (let i = 0; i < 12; i += 1) {
await arrow(page, "Down");
}
await arrow(page, "Down"); // core team -> Denmark
await arrow(page, "Down"); // Denmark -> sandbox
await arrow(page, "Up"); // sandbox -> Denmark
await arrow(page, "Up"); // Denmark -> core team
await arrow(page, "Up"); // core team -> core team
await arrow(page, "Down"); // core team -> Denmark
await arrow(page, "Down"); // Denmark -> sandbox
await arrow(page, "Down"); // sandbox-> Venice
await arrow(page, "Down"); // Venice -> Verona
await page.waitForSelector((await get_stream_li(page, "Verona")) + " .highlighted_row", {
visible: true,
});
await page.waitForSelector((await get_stream_li(page, "core team")) + " .highlighted_row", {
hidden: true,
});
await page.waitForSelector((await get_stream_li(page, "Denmark")) + " .highlighted_row", {
hidden: true,
});
await page.waitForSelector((await get_stream_li(page, "Venice")) + " .highlighted_row", {
hidden: true,
});
await page.waitForSelector((await get_stream_li(page, "Zulip")) + " .highlighted_row", {
hidden: true,
});
await test_search_venice(page);
// Search for beginning of "Verona".
await page.type(".left-sidebar-search-input", "ver");
await page.waitForSelector(await get_stream_li(page, "core team"), {hidden: true});
await page.waitForSelector(await get_stream_li(page, "Denmark"), {hidden: true});
await page.waitForSelector(await get_stream_li(page, "Venice"), {hidden: true});
await page.click(await get_stream_li(page, "Verona"));
await expect_verona_stream_top_topic(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".left-sidebar-search-input"),
"",
"Clicking on stream didn't clear search",
);
await un_narrow(page);
}
async function test_users_search(page: Page): Promise<void> {
console.log("Search users using right sidebar");
async function assert_in_list(page: Page, name: string): Promise<void> {
await page.waitForSelector(`#buddy-list-other-users li[data-name="${CSS.escape(name)}"]`, {
visible: true,
});
}
async function assert_selected(page: Page, name: string): Promise<void> {
await page.waitForSelector(
`#buddy-list-other-users li.highlighted_user[data-name="${CSS.escape(name)}"]`,
{visible: true},
);
}
async function assert_not_selected(page: Page, name: string): Promise<void> {
await page.waitForSelector(
`#buddy-list-other-users li.highlighted_user[data-name="${CSS.escape(name)}"]`,
{hidden: true},
);
}
await assert_in_list(page, "Desdemona");
await assert_in_list(page, "Cordelia, Lear's daughter");
await assert_in_list(page, "King Hamlet");
await assert_in_list(page, "aaron");
// Enter the search box and test selected suggestion navigation
await page.click(".user-list-filter");
// Selection is not highlighted until user wants to move the cursor.
await page.waitForSelector("#buddy-list-other-users .highlighted_user", {hidden: true});
await arrow(page, "Down");
await page.waitForSelector("#buddy-list-other-users .highlighted_user", {visible: true});
await assert_selected(page, "Desdemona");
await assert_not_selected(page, "Cordelia, Lear's daughter");
await assert_not_selected(page, "King Hamlet");
await assert_not_selected(page, "aaron");
// Navigate using arrow keys.
// go down 2, up 3, then down 3
// Desdemona
// aaron
// Cordelia, Lear's daughter
// Iago
await arrow(page, "Down");
await arrow(page, "Down");
await arrow(page, "Up");
await arrow(page, "Up");
await arrow(page, "Up"); // does nothing; already on the top.
await arrow(page, "Down");
await arrow(page, "Down");
await arrow(page, "Down");
// Now Iago must be highlighted
await page.waitForSelector('#buddy-list-other-users li.highlighted_user[data-name="Iago"]', {
visible: true,
});
await assert_not_selected(page, "King Hamlet");
await assert_not_selected(page, "aaron");
await assert_not_selected(page, "Desdemona");
// arrow up and press Enter. We should be taken to direct messages with Cordelia, Lear's daughter
await arrow(page, "Up");
await page.keyboard.press("Enter");
await expect_cordelia_direct_messages(page);
}
async function test_narrow_public_streams(page: Page): Promise<void> {
const stream_id = await common.get_stream_id(page, "Denmark");
await page.goto(`http://zulip.zulipdev.com:9981/#channels/${stream_id}/Denmark`);
await page.waitForSelector("button.sub_unsub_button", {visible: true});
await page.click("button.sub_unsub_button");
await page.waitForSelector(
`xpath///button[${common.has_class_x(
"sub_unsub_button",
)} and normalize-space()="Subscribe"]`,
);
await page.click("#subscription_overlay .two-pane-settings-header .exit-sign");
await page.waitForSelector("#subscription_overlay", {hidden: true});
await page.goto(`http://zulip.zulipdev.com:9981/#narrow/channel/${stream_id}-Denmark`);
let message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .recipient_row ~ .recipient_row ~ .recipient_row`,
);
assert.ok(
(await page.$(
`.message-list[data-message-list-id='${message_list_id}'] .stream-status, .message-list[data-message-list-id='${message_list_id}'] .not-subscribed-banner`,
)) !== null,
);
await page.goto("http://zulip.zulipdev.com:9981/#narrow/channels/public");
message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .recipient_row ~ .recipient_row ~ .recipient_row`,
);
assert.ok(
(await page.$(
`.message-list[data-message-list-id='${message_list_id}'] .stream-status`,
)) === null,
);
}
async function message_basic_tests(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
console.log("Sending messages");
await common.send_multiple_messages(page, [
{stream_name: "Verona", topic: "test", content: "verona test a"},
{stream_name: "Verona", topic: "test", content: "verona test b"},
{
stream_name: "Verona",
topic: "other topic",
content: "verona other topic c",
},
{stream_name: "Denmark", topic: "test", content: "denmark message"},
{
recipient: "cordelia@zulip.com, hamlet@zulip.com",
content: "group direct message a",
},
{
recipient: "cordelia@zulip.com, hamlet@zulip.com",
content: "group direct message b",
},
{recipient: "cordelia@zulip.com", content: "direct message c"},
{stream_name: "Verona", topic: "test", content: "verona test d"},
{
recipient: "cordelia@zulip.com, hamlet@zulip.com",
content: "group direct message d",
},
{recipient: "cordelia@zulip.com", content: "direct message e"},
]);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
await expect_home(page);
await test_navigations_from_home(page);
await search_tests(page);
await test_narrow_by_clicking_the_left_sidebar(page);
await test_stream_search_filters_stream_list(page);
await test_users_search(page);
await test_narrow_public_streams(page);
}
await common.run_test(message_basic_tests);

View File

@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function navigate_using_left_sidebar(page: Page, stream_name: string): Promise<void> {
console.log("Visiting #" + stream_name);
const stream_id = await page.evaluate(() => zulip_test.get_sub("Verona")!.stream_id);
await page.click(`.narrow-filter[data-stream-id="${stream_id}"] .stream-name`);
await page.waitForSelector("#message_view_header .zulip-icon-hashtag", {visible: true});
}
async function open_menu(page: Page): Promise<void> {
const menu_selector = "#settings-dropdown";
await page.waitForSelector(menu_selector, {visible: true});
await page.click(menu_selector);
}
async function navigate_to_settings(page: Page): Promise<void> {
console.log("Navigating to settings");
await common.open_personal_menu(page);
const settings_selector = "#personal-menu-dropdown a[href^='#settings']";
await page.waitForSelector(settings_selector, {visible: true});
await page.click(settings_selector);
const profile_section_tab_selector = "li[data-section='profile']";
await page.waitForSelector(profile_section_tab_selector, {visible: true});
await page.click(profile_section_tab_selector);
await page.waitForSelector(`${profile_section_tab_selector}:focus`, {visible: true});
await page.click("#settings_page .content-wrapper .exit");
// Wait until the overlay is completely closed.
await page.waitForSelector("#settings_overlay_container", {hidden: true});
}
async function navigate_to_subscriptions(page: Page): Promise<void> {
console.log("Navigate to subscriptions");
await open_menu(page);
const manage_streams_selector = '.link-item a[href^="#channels"]';
await page.waitForSelector(manage_streams_selector, {visible: true});
await page.click(manage_streams_selector);
await page.waitForSelector("#subscription_overlay", {visible: true});
await page.click("#subscription_overlay .exit");
// Wait until the overlay is completely closed.
await page.waitForSelector("#subscription_overlay", {hidden: true});
}
async function navigate_to_private_messages(page: Page): Promise<void> {
console.log("Navigate to direct messages");
const all_private_messages_icon = ".show-all-direct-messages";
await page.waitForSelector(all_private_messages_icon, {visible: true});
await page.click(all_private_messages_icon);
await page.waitForSelector("#message_view_header .zulip-icon-user", {visible: true});
}
async function test_reload_hash(page: Page): Promise<void> {
const initial_page_load_time = await page.evaluate(() => zulip_test.page_load_time);
assert.ok(initial_page_load_time !== undefined);
console.log(`initial load time: ${initial_page_load_time}`);
const initial_hash = await page.evaluate(() => window.location.hash);
await page.evaluate(() => {
zulip_test.initiate_reload({immediate: true});
});
await page.waitForNavigation();
const message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
visible: true,
});
const page_load_time = await page.evaluate(() => zulip_test.page_load_time);
assert.ok(page_load_time !== undefined);
assert.ok(page_load_time > initial_page_load_time, "Page not reloaded.");
const hash = await page.evaluate(() => window.location.hash);
assert.strictEqual(hash, initial_hash, "Hash not preserved.");
}
async function navigation_tests(page: Page): Promise<void> {
await common.log_in(page);
await navigate_to_settings(page);
await navigate_using_left_sidebar(page, "Verona");
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
await page.waitForSelector("#message_view_header .zulip-icon-all-messages", {visible: true});
await navigate_to_subscriptions(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
await page.waitForSelector("#message_view_header .zulip-icon-all-messages", {visible: true});
await navigate_to_settings(page);
await navigate_to_private_messages(page);
await navigate_to_subscriptions(page);
await navigate_using_left_sidebar(page, "Verona");
await test_reload_hash(page);
// Verify that we're narrowed to the target stream
await page.waitForSelector(
`xpath///*[@id="message_view_header"]//*[${common.has_class_x(
"message-header-stream-settings-button",
)} and normalize-space()="Verona"]`,
);
}
await common.run_test(navigation_tests);

View File

@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as z from "zod/mini";
import * as common from "./lib/common.ts";
const email = "alice@test.example.com";
const organization_name = "Awesome Organization";
const host = "zulipdev.com:9981";
async function realm_creation_tests(page: Page): Promise<void> {
await page.goto("http://" + host + "/new/");
// submit the email for realm creation.
await page.waitForSelector("#email");
await page.type("#email", email);
await page.type("#id_team_name", organization_name);
await page.select("#realm_type", "business");
await page.$eval("input#realm_in_root_domain", (el) => {
el.click();
});
await Promise.all([
page.waitForNavigation(),
page.$eval("form#create_realm", (form) => {
form.submit();
}),
]);
// Make sure confirmation email is sent.
assert.ok(page.url().includes("/accounts/new/send_confirm/?email=alice%40test.example.com"));
// Special endpoint enabled only during tests for extracting confirmation key
await page.goto("http://" + host + "/confirmation_key/");
// Open the confirmation URL
const page_content = await page.evaluate(() => document.querySelector("body")!.textContent);
assert.ok(page_content !== null);
const {confirmation_key} = z
.object({confirmation_key: z.string()})
.parse(JSON.parse(page_content));
const confirmation_url = `http://${host}/accounts/do_confirm/${confirmation_key}`;
await page.goto(confirmation_url);
// We wait until the DOMContentLoaded event because we want the code
// that focuses the first input field to run before we run our tests to avoid
// flakes. Without waiting for DOMContentLoaded event, in rare cases, the
// first input is focused when we are typing something for other fields causing
// validation errors. The code for focusing the input is wrapped in jQuery
// $() calls which runs when DOMContentLoaded is fired.
await page.waitForNavigation({waitUntil: "domcontentloaded"});
// Make sure the realm creation page is loaded correctly by
// checking the text in <p> tag under pitch class is as expected.
await page.waitForSelector(".pitch");
const text_in_pitch = await page.evaluate(
() => document.querySelector(".pitch p")!.textContent,
);
assert.equal(text_in_pitch, "Enter your account details to complete registration.");
// fill the form.
const params = {
full_name: "Alice",
password: "passwordwhichisnotreallycomplex",
terms: true,
how_realm_creator_found_zulip: "other",
how_realm_creator_found_zulip_other_text: "test",
};
// For some reason, page.click() does not work this for particular checkbox
// so use page.$eval here to call the .click method in the browser.
await common.fill_form(page, "#registration", params);
await page.$eval("form#registration", (form) => {
form.submit();
});
// Check if realm is created and user is logged in by checking if
// element of id `lightbox_overlay` exists.
await page.waitForSelector("#lightbox_overlay"); // if element doesn't exist,timeout error raises
// Check if the modal having the onboarding video has been displayed.
await common.wait_for_micromodal_to_open(page);
await page.click("#navigation-tour-video-modal .modal__close");
await common.wait_for_micromodal_to_close(page);
// Updating common.realm_url because we are redirecting to it when logging out.
common.set_realm_url(page.url());
}
await common.run_test(realm_creation_tests);

View File

@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function test_add_linkifier(page: Page): Promise<void> {
await page.waitForSelector("#add-linkifier-button", {visible: true});
await page.click("#add-linkifier-button");
await common.wait_for_micromodal_to_open(page);
await page.waitForSelector("form.linkifier-add-form", {visible: true});
await common.fill_form(page, "form.linkifier-add-form", {
pattern: "#(?P<id>[0-9]+)",
url_template: "https://trac.example.com/ticket/{id}",
});
await page.click(".dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
const admin_linkifier_status_selector = "div#linkifier-field-status";
await page.waitForSelector(admin_linkifier_status_selector, {visible: true});
const admin_linkifier_status = await common.get_text_from_selector(
page,
admin_linkifier_status_selector,
);
assert.strictEqual(admin_linkifier_status, "Custom linkifier added!");
await page.waitForSelector(".linkifier_row:nth-child(4)", {visible: true});
assert.strictEqual(
await common.get_text_from_selector(
page,
".linkifier_row:nth-child(4) span.linkifier_pattern",
),
"#(?P<id>[0-9]+)",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
".linkifier_row:nth-child(4) span.linkifier_url_template",
),
"https://trac.example.com/ticket/{id}",
);
}
async function test_delete_linkifier(page: Page): Promise<void> {
await page.waitForFunction(() => document.querySelectorAll(".linkifier_row").length === 4);
await page.click(".linkifier_row:nth-last-child(1) .delete");
await common.wait_for_micromodal_to_open(page);
await page.click("#confirm_delete_linkifiers_modal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForFunction(() => document.querySelectorAll(".linkifier_row").length === 3);
}
async function test_add_invalid_linkifier_pattern(page: Page): Promise<void> {
await page.click("#add-linkifier-button");
await common.wait_for_micromodal_to_open(page);
await page.waitForSelector("form.linkifier-add-form", {visible: true});
await common.fill_form(page, "form.linkifier-add-form", {
pattern: "(foo",
url_template: "https://trac.example.com/ticket/{id}",
});
await page.click(".dialog_submit_button");
// Invalid patterns are rejected client-side by RE2JS compilation before
// the form is submitted; the error appears in the pattern-specific status
// element and includes RE2JS's description of the compilation failure.
await page.waitForSelector("div#linkifier-pattern-status", {visible: true});
assert.strictEqual(
await common.get_text_from_selector(page, "div#linkifier-pattern-status"),
"Failed: Bad regular expression: missing closing )",
);
await page.click(".dialog_exit_button");
await common.wait_for_micromodal_to_close(page);
}
async function test_edit_linkifier(page: Page): Promise<void> {
await page.waitForSelector(".linkifier_row:nth-last-child(1) .edit", {visible: true});
await page.click(".linkifier_row:nth-last-child(1) .edit");
await common.wait_for_micromodal_to_open(page);
await common.fill_form(page, "form.linkifier-edit-form", {
pattern: "(?P<num>[0-9a-f]{40})",
url_template: "https://trac.example.com/commit/{num}",
});
await page.click(".dialog_submit_button");
await page.waitForSelector(".micromodal", {hidden: true});
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(".linkifier_row:nth-last-child(1)", {visible: true});
await page.waitForFunction(
() =>
document.querySelector(".linkifier_row:nth-last-child(1) span.linkifier_pattern")
?.textContent === "(?P<num>[0-9a-f]{40})",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
".linkifier_row:nth-last-child(1) span.linkifier_url_template",
),
"https://trac.example.com/commit/{num}",
);
}
async function test_edit_invalid_linkifier(page: Page): Promise<void> {
await page.waitForSelector(".linkifier_row:nth-last-child(1) .edit", {visible: true});
await page.click(".linkifier_row:nth-last-child(1) .edit");
await common.wait_for_micromodal_to_open(page);
await common.fill_form(page, "form.linkifier-edit-form", {
pattern: "#(?P<id>d????)",
url_template: "{id",
});
await page.click(".dialog_submit_button");
// Invalid patterns are rejected client-side by RE2JS, so the form
// does not submit and the URL template is never checked server-side.
const edit_linkifier_pattern_status_selector = "div#linkifier-pattern-status";
await page.waitForSelector(edit_linkifier_pattern_status_selector, {visible: true});
assert.strictEqual(
await common.get_text_from_selector(page, edit_linkifier_pattern_status_selector),
"Failed: Bad regular expression: invalid nested repetition operator",
);
// Fix the pattern; the invalid URL template now surfaces from the server.
await common.fill_form(page, "form.linkifier-edit-form", {
pattern: "#(?P<id>[0-9]+)",
});
await page.click(".dialog_submit_button");
const edit_linkifier_template_status_selector = "div#linkifier-template-status";
await page.waitForSelector(edit_linkifier_template_status_selector, {visible: true});
assert.strictEqual(
await common.get_text_from_selector(page, edit_linkifier_template_status_selector),
"Failed: Invalid URL template.",
);
await page.click(".dialog_exit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(".linkifier_row:nth-last-child(1)", {visible: true});
assert.strictEqual(
await common.get_text_from_selector(
page,
".linkifier_row:nth-last-child(1) span.linkifier_pattern",
),
"(?P<num>[0-9a-f]{40})",
);
assert.strictEqual(
await common.get_text_from_selector(
page,
".linkifier_row:nth-last-child(1) span.linkifier_url_template",
),
"https://trac.example.com/commit/{num}",
);
}
async function linkifier_test(page: Page): Promise<void> {
await common.log_in(page);
await common.manage_organization(page);
await page.click("li[data-section='linkifier-settings']");
await test_add_linkifier(page);
await test_edit_linkifier(page);
await test_edit_invalid_linkifier(page);
await test_add_invalid_linkifier_pattern(page);
await test_delete_linkifier(page);
}
await common.run_test(linkifier_test);

View File

@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
type Playground = {
playground_name: string;
pygments_language: string;
url_template: string;
};
async function _add_playground_and_return_status(page: Page, payload: Playground): Promise<string> {
await page.waitForSelector(".admin-playground-form", {visible: true});
const admin_playground_status_selector = "div#admin-playground-status";
await common.select_item_via_typeahead(
page,
"#playground_pygments_language",
payload.pygments_language,
payload.pygments_language,
);
// Now we can fill and click the submit button.
await common.fill_form(page, "form.admin-playground-form", {
playground_name: payload.playground_name,
url_template: payload.url_template,
});
// Not sure why, but page.click() doesn't seem to always click the submit button.
// So we resort to using eval with the button ID instead.
await page.$eval("button#submit_playground_button", (el) => {
el.click();
});
// Wait for the request to complete by checking when the button is re-enabled.
await page.waitForSelector("button#submit_playground_button:not([disabled])", {visible: true});
// We return the success/failure status message back to the caller.
await page.waitForSelector(admin_playground_status_selector, {visible: true});
const admin_playground_status = await common.get_text_from_selector(
page,
admin_playground_status_selector,
);
return admin_playground_status;
}
async function test_successful_playground_creation(page: Page): Promise<void> {
const payload = {
pygments_language: "Python",
playground_name: "Python3 playground",
url_template: "https://python.example.com?code={code}",
};
const status = await _add_playground_and_return_status(page, payload);
assert.strictEqual(status, "Custom playground added!");
await page.waitForSelector(".playground_row", {visible: true});
assert.strictEqual(
await common.get_text_from_selector(
page,
".playground_row span.playground_pygments_language",
),
"Python",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".playground_row span.playground_name"),
"Python3 playground",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".playground_row span.playground_url_template"),
"https://python.example.com?code={code}",
);
}
async function test_invalid_playground_parameters(page: Page): Promise<void> {
const payload = {
pygments_language: "Python",
playground_name: "Python3 playground",
url_template: "not_a_url_template{",
};
let status = await _add_playground_and_return_status(page, payload);
assert.strictEqual(status, "Failed: Invalid URL template.");
payload.url_template = "https://python.example.com?code={code}";
payload.pygments_language = "py!@%&";
status = await _add_playground_and_return_status(page, payload);
assert.strictEqual(status, "Failed: Invalid character in language: !");
}
async function test_successful_playground_deletion(page: Page): Promise<void> {
await page.waitForSelector(".playground_row button.delete", {visible: true});
await page.click(".playground_row button.delete");
await common.wait_for_micromodal_to_open(page);
await page.click("#confirm_delete_code_playgrounds_modal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(".playground_row", {hidden: true});
}
async function playground_test(page: Page): Promise<void> {
await common.log_in(page);
await common.manage_organization(page);
await page.click("li[data-section='playground-settings']");
await test_successful_playground_creation(page);
await test_invalid_playground_parameters(page);
await test_successful_playground_deletion(page);
}
await common.run_test(playground_test);

View File

@@ -0,0 +1,531 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import {GENERIC_BOT_TYPE, OUTGOING_WEBHOOK_BOT_TYPE} from "../src/bot_type_values.ts";
import * as common from "./lib/common.ts";
import {test_credentials} from "./lib/common.ts";
const zuliprc_regex =
/^data:application\/octet-stream;charset=utf-8,\[api]\nemail=.+\nkey=.+\nsite=.+\n$/;
async function get_decoded_url_in_selector(page: Page, selector: string): Promise<string> {
const a = await page.$(`a:is(${selector})`);
return decodeURIComponent(await (await a!.getProperty("href")).jsonValue());
}
async function open_manage_bot_tab(page: Page, user_id: number): Promise<void> {
const manage_button_selector = `#personal_your_bots_table .user_row[data-user-id="${user_id}"] .manage-user-button`;
await page.waitForSelector(manage_button_selector, {visible: true});
await page.click(manage_button_selector);
// Wait for modal, then go to tab
await common.wait_for_micromodal_to_open(page);
}
async function open_settings(page: Page): Promise<void> {
await common.open_personal_menu(page);
const settings_selector = "#personal-menu-dropdown a[href^='#settings']";
await page.waitForSelector(settings_selector, {visible: true});
await page.click(settings_selector);
await page.waitForSelector("#settings_content .profile-settings-form", {visible: true});
const page_url = await common.page_url_with_fragment(page);
assert.ok(
page_url.includes("/#settings/"),
`Page url: ${page_url} does not contain /#settings/`,
);
// Wait for settings overlay to open.
await page.waitForSelector("#settings_overlay_container", {visible: true});
}
async function close_settings_and_date_picker(page: Page): Promise<void> {
const date_picker_selector = ".date-field-alt-input";
await page.click(date_picker_selector);
await page.waitForSelector(".flatpickr-calendar", {visible: true});
await page.keyboard.press("Escape");
await page.waitForSelector(".flatpickr-calendar", {hidden: true});
await page.waitForSelector("#settings_overlay_container", {hidden: true});
}
async function test_change_full_name(page: Page): Promise<void> {
await page.waitForSelector("#full_name", {visible: true});
await page.click("#full_name");
const full_name_input_selector = 'input[name="full_name"]';
await common.clear_and_type(page, full_name_input_selector, "New name");
await page.click("#settings_content .profile-settings-form");
await page.waitForSelector(".full-name-change-container .alert-success", {visible: true});
await page.waitForFunction(
() => document.querySelector<HTMLInputElement>("#full_name")?.value === "New name",
);
}
async function test_change_password(page: Page): Promise<void> {
await page.click("#change_password");
const change_password_button_selector = "#change_password_modal .dialog_submit_button";
await page.waitForSelector(change_password_button_selector, {visible: true});
await common.wait_for_micromodal_to_open(page);
await page.type("#old_password", test_credentials.default_user.password);
test_credentials.default_user.password = "new_password";
await page.type("#new_password", test_credentials.default_user.password);
// The password strength checker (zxcvbn) is imported asynchronously,
// and submitting before it loads makes the handler bail out with an
// internal error, leaving the modal open. The strength bar is hidden
// until the module finishes loading, so wait for it to become visible.
await page.waitForSelector("#pw_strength .bar", {visible: true});
await page.click(change_password_button_selector);
// On success the change password modal gets closed.
await common.wait_for_micromodal_to_close(page);
}
async function test_get_api_key(page: Page): Promise<void> {
await page.click('[data-section="account-and-privacy"]');
const show_change_api_key_selector = "#api_key_button";
await page.waitForSelector(show_change_api_key_selector, {visible: true});
await page.click(show_change_api_key_selector);
const get_api_key_button_selector = "#get_api_key_button";
await page.waitForSelector(get_api_key_button_selector, {visible: true});
await common.wait_for_micromodal_to_open(page);
await common.fill_form(page, "#api_key_form", {
password: test_credentials.default_user.password,
});
// When typing the password in Firefox, it shows "Not Secure" warning
// which was hiding the Get API key button.
// You can see the screenshot of it in https://github.com/zulip/zulip/pull/17136.
// Focusing on it will remove the warning.
await page.focus(get_api_key_button_selector);
await page.click(get_api_key_button_selector);
await page.waitForSelector("#show_api_key", {visible: true});
const api_key = await common.get_text_from_selector(page, "#api_key_value");
assert.match(api_key, /[\dA-Za-z]{32}/, "Incorrect API key format.");
const download_zuliprc_selector = "#download_zuliprc";
await page.click(download_zuliprc_selector);
const zuliprc_decoded_url = await get_decoded_url_in_selector(page, download_zuliprc_selector);
assert.match(zuliprc_decoded_url, zuliprc_regex, "Incorrect zuliprc file");
await page.click("#api_key_modal .modal__close");
await common.wait_for_micromodal_to_close(page);
}
async function test_webhook_bot_creation(page: Page): Promise<void> {
await page.waitForSelector("#personal-bot-list .add-a-new-bot", {visible: true});
await page.click("#personal-bot-list .add-a-new-bot");
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Add a new bot",
"Unexpected title for deactivate user modal",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
"Add",
"Deactivate button has incorrect text.",
);
await common.fill_form(page, "#create_bot_form", {
bot_name: "Bot 1",
bot_short_name: "1",
bot_type: OUTGOING_WEBHOOK_BOT_TYPE,
payload_url: "http://hostname.example.com/bots/followup",
});
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
// Wait for the bot to appear in the local data store, since the
// bot_add event may not have been processed yet when the modal closes.
await page.waitForFunction(
(bot_name: string) => zulip_test.get_user_id_from_name(bot_name) !== undefined,
{},
"Bot 1",
);
const user_id = await common.get_user_id_from_name(page, "Bot 1");
await open_manage_bot_tab(page, user_id!);
const outgoing_webhook_zuliprc_regex =
/^data:application\/octet-stream;charset=utf-8,\[api]\nemail=.+\nkey=.+\nsite=.+\ntoken=.+\n$/;
const zuliprc_url_selector = `.micromodal .hidden-zuliprc-download`;
const download_zuliprc_selector = `.download-bot-zuliprc`;
await page.waitForSelector(download_zuliprc_selector, {visible: true});
await page.click(download_zuliprc_selector);
// Wait for API request to be complete by checking when the loading indicator
// is shown and hidden.
const download_zuliprc_loader_selector = ".download-bot-zuliprc .button-loading-indicator";
await page.waitForSelector(download_zuliprc_loader_selector, {visible: true});
await page.waitForSelector(download_zuliprc_loader_selector, {hidden: true});
const zuliprc_decoded_url = await get_decoded_url_in_selector(page, zuliprc_url_selector);
assert.match(
zuliprc_decoded_url,
outgoing_webhook_zuliprc_regex,
"Incorrect outgoing webhook bot zuliprc format",
);
await page.click(".micromodal .modal__close");
await common.wait_for_micromodal_to_close(page);
}
async function test_normal_bot_creation(page: Page): Promise<void> {
await page.waitForSelector("#personal-bot-list .add-a-new-bot", {visible: true});
await page.click("#personal-bot-list .add-a-new-bot");
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Add a new bot",
"Unexpected title for deactivate user modal",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button span"),
"Add",
"Deactivate button has incorrect text.",
);
await common.fill_form(page, "#create_bot_form", {
bot_name: "Bot 2",
bot_short_name: "2",
bot_type: GENERIC_BOT_TYPE,
});
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
// Wait for the bot to appear in the local data store, since the
// bot_add event may not have been processed yet when the modal closes.
await page.waitForFunction(
(bot_name: string) => zulip_test.get_user_id_from_name(bot_name) !== undefined,
{},
"Bot 2",
);
const user_id = await common.get_user_id_from_name(page, "Bot 2");
await open_manage_bot_tab(page, user_id!);
const zuliprc_url_selector = `.micromodal .hidden-zuliprc-download`;
const download_zuliprc_selector = `.download-bot-zuliprc`;
await page.waitForSelector(download_zuliprc_selector, {visible: true});
await page.click(download_zuliprc_selector);
// Wait for API request to be complete by checking when the loading indicator
// is shown and hidden.
const download_zuliprc_loader_selector = ".download-bot-zuliprc .button-loading-indicator";
await page.waitForSelector(download_zuliprc_loader_selector, {visible: true});
await page.waitForSelector(download_zuliprc_loader_selector, {hidden: true});
const zuliprc_decoded_url = await get_decoded_url_in_selector(page, zuliprc_url_selector);
assert.match(zuliprc_decoded_url, zuliprc_regex, "Incorrect zuliprc format for bot.");
await page.click(".micromodal .modal__close");
await common.wait_for_micromodal_to_close(page);
}
async function test_botserverrc(page: Page): Promise<void> {
await page.waitForSelector("#personal-bot-list .download-botserverrc-file", {visible: true});
await page.click("#personal-bot-list .download-botserverrc-file");
await page.waitForSelector(
'#personal-bot-list .hidden-botserverrc-download[href^="data:application"]',
);
const botserverrc_decoded_url = await get_decoded_url_in_selector(
page,
"#personal-bot-list .hidden-botserverrc-download",
);
const botserverrc_regex =
/^data:application\/octet-stream;charset=utf-8,\[]\nemail=.+\nkey=.+\nsite=.+\ntoken=.+\n$/;
assert.match(botserverrc_decoded_url, botserverrc_regex, "Incorrect botserverrc format.");
}
// Disabled the below test due to non-deterministic failures.
// The test often fails to close the modal, as does the
// test_invalid_edit_bot_form above.
// TODO: Debug this and re-enable with a fix.
async function test_edit_bot_form(page: Page): Promise<void> {
return;
const bot1_email = "1-bot@zulip.testserver";
const bot1_edit_button = `.open_edit_bot_form[data-email="${CSS.escape(bot1_email)}"]`;
await page.click(bot1_edit_button);
const edit_form_selector = `#bot-edit-form[data-email="${CSS.escape(bot1_email)}"]`;
await page.waitForSelector(edit_form_selector, {visible: true});
assert.equal(
await page.$eval(`${edit_form_selector} input[name=full_name]`, (el) => el.value),
"Bot 1",
);
await common.fill_form(page, edit_form_selector, {full_name: "Bot one"});
const save_button_selector = "#user-profile-modal .dialog_submit_button";
await page.click(save_button_selector);
// The form gets closed on saving. So, assert it's closed by waiting for it to be hidden.
await page.waitForSelector("#edit_bot_modal", {hidden: true});
await page.waitForSelector(
`xpath///*[${common.has_class_x(
"open_edit_bot_form",
)} and @data-email="${bot1_email}"]/ancestor::*[${common.has_class_x(
"details",
)}]/*[${common.has_class_x("name")} and text()="Bot one"]`,
);
await common.wait_for_micromodal_to_close(page);
}
// Disabled the below test due to non-deterministic failures.
// The test often fails to close the modal.
// TODO: Debug this and re-enable with a fix.
async function test_invalid_edit_bot_form(page: Page): Promise<void> {
return;
const bot1_email = "1-bot@zulip.testserver";
const bot1_edit_button = `.open_edit_bot_form[data-email="${CSS.escape(bot1_email)}"]`;
await page.click(bot1_edit_button);
const edit_form_selector = `#bot-edit-form[data-email="${CSS.escape(bot1_email)}"]`;
await page.waitForSelector(edit_form_selector, {visible: true});
assert.equal(
await page.$eval(`${edit_form_selector} input[name=full_name]`, (el) => el.value),
"Bot one",
);
await common.fill_form(page, edit_form_selector, {full_name: "Bot 2"});
const save_button_selector = "#user-profile-modal .dialog_submit_button";
await page.click(save_button_selector);
// The form should not get closed on saving. Errors should be visible on the form.
await common.wait_for_micromodal_to_open(page);
await page.waitForSelector("#dialog_error", {visible: true});
assert.strictEqual(
await common.get_text_from_selector(page, "#dialog_error"),
"Failed: Name is already in use.",
);
const cancel_button_selector = "#user-profile-modal .dialog_exit_button";
await page.waitForFunction(
(cancel_button_selector: string) =>
!document.querySelector(cancel_button_selector)?.hasAttribute("disabled"),
{},
cancel_button_selector,
);
await page.click(cancel_button_selector);
await page.waitForSelector(
`xpath///*[${common.has_class_x(
"open_edit_bot_form",
)} and @data-email="${bot1_email}"]/ancestor::*[${common.has_class_x(
"details",
)}]/*[${common.has_class_x("name")} and text()="Bot one"]`,
);
await common.wait_for_micromodal_to_close(page);
}
async function test_your_bots_section(page: Page): Promise<void> {
await page.click('.normal-settings-list [data-section="bots"]');
await test_webhook_bot_creation(page);
await test_normal_bot_creation(page);
await test_botserverrc(page);
await test_edit_bot_form(page);
await test_invalid_edit_bot_form(page);
}
const alert_word_status_banner_selector = ".alert-word-status-banner";
async function add_alert_word(page: Page, word: string): Promise<void> {
await page.click("#open-add-alert-word-modal");
await common.wait_for_micromodal_to_open(page);
await page.type("#add-alert-word-name", word);
await page.click("#add-alert-word .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
}
async function check_alert_word_added(page: Page, word: string): Promise<void> {
const added_alert_word_selector = `.alert-word-item[data-word='${CSS.escape(word)}']`;
await page.waitForSelector(added_alert_word_selector, {visible: true});
}
async function get_alert_words_status_text(page: Page): Promise<string> {
await page.waitForSelector(alert_word_status_banner_selector, {visible: true});
const status_text = await common.get_text_from_selector(
page,
".alert-word-status-banner .banner-label",
);
return status_text;
}
async function close_alert_words_status(page: Page): Promise<void> {
const status_close_button = ".alert-word-status-banner .banner-close-button";
await page.click(status_close_button);
await page.waitForSelector(alert_word_status_banner_selector, {hidden: true});
}
async function test_duplicate_alert_words_cannot_be_added(
page: Page,
duplicate_word: string,
): Promise<void> {
await page.click("#open-add-alert-word-modal");
await common.wait_for_micromodal_to_open(page);
await page.type("#add-alert-word-name", duplicate_word);
await page.click("#add-alert-word .dialog_submit_button");
const alert_word_status_selector = "#dialog_error";
await page.waitForSelector(alert_word_status_selector, {visible: true});
const status_text = await common.get_text_from_selector(page, alert_word_status_selector);
assert.strictEqual(status_text, "Alert word already exists!");
await page.click("#add-alert-word .dialog_exit_button");
await common.wait_for_micromodal_to_close(page);
}
async function delete_alert_word(page: Page, word: string): Promise<void> {
const delete_button_selector = `tr[data-word="${CSS.escape(word)}"] .remove-alert-word`;
await page.click(delete_button_selector);
await page.waitForSelector(delete_button_selector, {hidden: true});
}
async function test_alert_word_deletion(page: Page, word: string): Promise<void> {
await delete_alert_word(page, word);
const status_text = await get_alert_words_status_text(page);
assert.strictEqual(status_text, `Alert word ${word} removed successfully!`);
await close_alert_words_status(page);
}
async function test_alert_words_section(page: Page): Promise<void> {
await page.click('[data-section="alert-words"]');
const word = "puppeteer";
await add_alert_word(page, word);
await check_alert_word_added(page, word);
await test_duplicate_alert_words_cannot_be_added(page, word);
await test_alert_word_deletion(page, word);
}
async function change_language(page: Page, language_data_code: string): Promise<void> {
await page.waitForSelector("#default_language_widget", {
visible: true,
});
await page.click("#default_language_widget");
await page.waitForSelector(".dropdown-list", {visible: true});
const language_selector = `li[data-unique-id="${CSS.escape(language_data_code)}"]`;
await page.click(language_selector);
}
async function check_language_setting_status(page: Page): Promise<void> {
await page.waitForSelector("#user-preferences .general-settings-status .reload_link", {
visible: true,
});
}
async function assert_language_changed_to_chinese(page: Page): Promise<void> {
await page.waitForSelector("#default_language_widget", {
visible: true,
});
const default_language = await common.get_text_from_selector(page, ".dropdown_widget_value");
assert.match(
default_language,
/^中文(简体)/v,
"Default language has not been changed to Chinese.",
);
}
async function test_i18n_language_precedence(page: Page): Promise<void> {
const settings_url_for_german = "http://zulip.zulipdev.com:9981/de/#settings";
await page.goto(settings_url_for_german);
await page.waitForSelector("#settings-change-box", {visible: true});
const page_language_code = await page.evaluate(() => document.documentElement.lang);
assert.strictEqual(page_language_code, "de");
}
async function test_default_language_setting(page: Page): Promise<void> {
// Since the "Bots" section of Personal Settings redirects us to Organization Settings > Bots with the "Your Bots" tab preselected,
// we need to switch back to the Personal Settings tab to proceed with further testing.
await page.waitForSelector('.tab-switcher .ind-tab[data-tab-key="settings"]', {visible: true});
await page.click('.tab-switcher .ind-tab[data-tab-key="settings"]');
await page.waitForSelector("#settings_overlay_container", {visible: true});
const preferences_section = '[data-section="preferences"]';
await page.click(preferences_section);
const chinese_language_data_code = "zh-hans";
await change_language(page, chinese_language_data_code);
// Check that the saved indicator appears
await check_language_setting_status(page);
await page.click(".reload_link");
await page.waitForSelector("#default_language_widget", {
visible: true,
});
await assert_language_changed_to_chinese(page);
await test_i18n_language_precedence(page);
await page.waitForSelector(preferences_section, {visible: true});
await page.click(preferences_section);
// Change the language back to English so that subsequent tests pass.
await change_language(page, "en");
// Check that the saved indicator appears
await check_language_setting_status(page);
await page.goto("http://zulip.zulipdev.com:9981/#settings"); // get back to normal language.
await page.waitForSelector(preferences_section, {visible: true});
await page.click(preferences_section);
await page.waitForSelector("#user-preferences .general-settings-status", {
visible: true,
});
await page.waitForSelector("#default_language_widget", {
visible: true,
});
}
async function test_notifications_section(page: Page): Promise<void> {
await page.click('[data-section="notifications"]');
// At the beginning, "DMs, mentions, and alerts"(checkbox name=enable_sounds) audio will be on
// and "Streams"(checkbox name=enable_stream_audible_notifications) audio will be off by default.
const notification_sound_enabled =
"#user-notification-settings .setting_notification_sound:enabled";
await page.waitForSelector(notification_sound_enabled, {visible: true});
await common.fill_form(page, "#user-notification-settings .notification-settings-form", {
enable_stream_audible_notifications: true,
enable_sounds: false,
});
await page.waitForSelector(notification_sound_enabled, {visible: true});
await common.fill_form(page, "#user-notification-settings .notification-settings-form", {
enable_stream_audible_notifications: true,
});
/*
Usually notifications sound dropdown gets disabled on disabling
all audio notifications. But this seems flaky in tests.
TODO: Find the right fix and enable this.
const notification_sound_disabled = ".setting_notification_sound:disabled";
await page.waitForSelector(notification_sound_disabled);
*/
}
async function settings_tests(page: Page): Promise<void> {
await common.log_in(page);
await open_settings(page);
await close_settings_and_date_picker(page);
await open_settings(page);
await test_change_full_name(page);
await test_alert_words_section(page);
await test_your_bots_section(page);
await test_default_language_setting(page);
await test_notifications_section(page);
await test_get_api_key(page);
await test_change_password(page);
// test_change_password should be the very last test, because it
// replaces your session, which can lead to some nondeterministic
// failures in test code after it, involving `GET /events`
// returning a 401. (We reset the test database after each file).
}
await common.run_test(settings_tests);

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
const message = "test star";
async function stars_count(page: Page): Promise<number> {
return (await page.$$(".message-list .zulip-icon-star-filled:not(.empty-star)")).length;
}
async function toggle_test_star_message(page: Page): Promise<void> {
const messagebox = await page.waitForSelector(
`xpath/(//*[${common.has_class_x("message-list")}]//*[${common.has_class_x(
"message_content",
)} and normalize-space()="${message}"])[last()]/ancestor::*[${common.has_class_x(
"messagebox",
)}]`,
{visible: true},
);
assert.ok(messagebox !== null);
await messagebox.hover();
const star_icon = await messagebox.waitForSelector(".star", {visible: true});
assert.ok(star_icon !== null);
await star_icon.click();
}
async function test_narrow_to_starred_messages(page: Page): Promise<void> {
await page.click('#left-sidebar-navigation-list a[href^="#narrow/is/starred"]');
const message_list_id = await common.get_current_msg_list_id(page, true);
await common.check_messages_sent(page, message_list_id, [["Verona > stars", [message]]]);
// Go back to the combined feed view.
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
const combined_feed_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${combined_feed_id}'] .message_row`,
{visible: true},
);
}
async function stars_test(page: Page): Promise<void> {
await common.log_in(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
let message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
{visible: true},
);
// Assert that there is only one message list.
assert.equal((await page.$$(".message-list")).length, 1);
await common.send_message(page, "stream", {
stream_name: "Verona",
topic: "stars",
content: message,
});
assert.strictEqual(await stars_count(page), 0, "Unexpected already starred message(s).");
await toggle_test_star_message(page);
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
message_list_id = await common.get_current_msg_list_id(page, true);
await page.waitForSelector(
`.message-list[data-message-list-id='${message_list_id}'] .zulip-icon-star-filled`,
{visible: true},
);
assert.strictEqual(
await stars_count(page),
1,
"Failed to ensure 1 starred message after change.",
);
await test_narrow_to_starred_messages(page);
assert.strictEqual(
await stars_count(page),
1,
"Message star disappeared after switching views.",
);
await toggle_test_star_message(page);
assert.strictEqual(await stars_count(page), 0, "Message was not unstarred correctly.");
}
await common.run_test(stars_test);

View File

@@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function user_row_selector(page: Page, name: string): Promise<string> {
const user_id = await common.get_user_id_from_name(page, name);
const selector = `.settings-subscriber-row[data-user-id="${user_id}"]`;
return selector;
}
async function await_user_visible(page: Page, name: string): Promise<void> {
const selector = await user_row_selector(page, name);
await page.waitForSelector(selector, {visible: true});
}
async function await_user_hidden(page: Page, name: string): Promise<void> {
const selector = await user_row_selector(page, name);
await page.waitForSelector(selector, {hidden: true});
}
async function add_user_to_stream(page: Page, name: string): Promise<void> {
const user_id = await common.get_user_id_from_name(page, name);
assert.ok(user_id !== undefined);
await page.evaluate((user_id) => {
zulip_test.add_user_id_to_new_stream(user_id);
}, user_id);
await await_user_visible(page, name);
}
async function stream_name_error(page: Page): Promise<string> {
await page.waitForSelector("#stream_name_error", {visible: true});
return await common.get_text_from_selector(page, "#stream_name_error");
}
async function click_create_new_stream(page: Page): Promise<void> {
await page.click("#add_new_subscription .create_stream_button");
await page.type("#create_stream_name", "Test Stream 1");
await page.click("#stream_creation_go_to_subscribers");
await page.waitForSelector(".finalize_create_stream", {visible: true});
// sanity check that desdemona is the initial subsscriber
await await_user_visible(page, "desdemona");
}
async function clear_ot_filter_with_backspace(page: Page): Promise<void> {
await page.click("#people_to_add input.search");
await page.keyboard.press("Backspace");
await page.keyboard.press("Backspace");
}
async function test_user_filter_ui(page: Page): Promise<void> {
await page.waitForSelector("form#stream_creation_form", {visible: true});
// Desdemona should be there by default
await await_user_visible(page, "desdemona");
await add_user_to_stream(page, common.fullname.cordelia);
await add_user_to_stream(page, common.fullname.othello);
await page.type(`#people_to_add input.search`, "ot", {delay: 100});
await page.waitForSelector("#create_stream_subscribers", {visible: true});
// Wait until filtering is completed.
await page.waitForFunction(
() =>
document.querySelectorAll("#create_stream_subscribers .remove_potential_subscriber")
.length === 1,
);
await await_user_hidden(page, common.fullname.cordelia);
await await_user_hidden(page, "desdemona");
await await_user_visible(page, common.fullname.othello);
// Clear the filter.
await clear_ot_filter_with_backspace(page);
await await_user_visible(page, common.fullname.cordelia);
await await_user_visible(page, "desdemona");
await await_user_visible(page, common.fullname.othello);
}
async function create_stream(page: Page): Promise<void> {
await page.click("#stream_creation_go_to_configure_channel_settings");
await page.waitForSelector('xpath///*[text()="Configure new channel settings"]', {
visible: true,
});
await common.fill_form(page, "form#stream_creation_form", {
stream_name: "Puppeteer",
stream_description: "Everything Puppeteer",
});
await page.click("#stream_creation_go_to_subscribers");
await page.type("#create_stream_name", "Test Stream 2");
await page.click("form#stream_creation_form .finalize_create_stream");
// We redirect to the channel message view.
await page.waitForSelector("#subscription_overlay", {hidden: true});
await page.waitForSelector(
`xpath///*[${common.has_class_x("message-header-navbar-title")} and text()="Puppeteer"]`,
);
await page.waitForSelector(".message-header-stream-settings-button");
await page.click(".message-header-stream-settings-button");
await page.waitForSelector(".stream_section");
await page.waitForSelector(
`xpath///*[${common.has_class_x("stream-name-title")} and text()="Puppeteer"]`,
{visible: true},
);
const stream_name = await common.get_text_from_selector(
page,
".selected-stream-title .stream-name-title",
);
const stream_description = await common.get_text_from_selector(
page,
".stream-description .sub-stream-description",
);
assert.strictEqual(stream_name, "Puppeteer");
assert.strictEqual(stream_description, "Everything Puppeteer");
// Assert subscriber count becomes 3 (cordelia, desdemona, othello)
await page.waitForSelector(
`xpath///*[@data-stream-name="Puppeteer"]//*[${common.has_class_x(
"subscriber-count",
)} and normalize-space()="3"]`,
);
}
async function test_streams_with_empty_names_cannot_be_created(page: Page): Promise<void> {
await page.click("#add_new_subscription .create_stream_button");
await page.waitForSelector("form#stream_creation_form", {visible: true});
await common.fill_form(page, "form#stream_creation_form", {stream_name: " "});
await page.click("form#stream_creation_form button#stream_creation_go_to_subscribers");
assert.strictEqual(await stream_name_error(page), "Choose a name for the new channel.");
}
async function test_streams_with_duplicate_names_cannot_be_created(page: Page): Promise<void> {
await common.fill_form(page, "form#stream_creation_form", {stream_name: "Puppeteer"});
await page.click("form#stream_creation_form button#stream_creation_go_to_subscribers");
assert.strictEqual(await stream_name_error(page), "A channel with this name already exists.");
const cancel_button_selector = "form#stream_creation_form button.create_stream_cancel";
await page.click(cancel_button_selector);
}
async function test_stream_creation(page: Page): Promise<void> {
await click_create_new_stream(page);
await test_user_filter_ui(page);
await create_stream(page);
await test_streams_with_empty_names_cannot_be_created(page);
await test_streams_with_duplicate_names_cannot_be_created(page);
}
async function test_streams_search_feature(page: Page): Promise<void> {
assert.strictEqual(await common.get_text_from_selector(page, "#search_stream_name"), "");
const hidden_streams_selector = ".stream-row.notdisplayed .stream-name";
assert.strictEqual(
await common.get_text_from_selector(
page,
'.stream-row[data-stream-name="Verona"] .stream-name',
),
"Verona",
);
assert.ok(
!(await common.get_text_from_selector(page, hidden_streams_selector)).includes("Verona"),
"#Verona is hidden",
);
await page.type('#stream_filter input[type="text"]', "Puppeteer");
await page.waitForSelector(".stream-row[data-stream-name='core team']", {hidden: true});
assert.strictEqual(
await common.get_text_from_selector(page, ".stream-row:not(.notdisplayed) .stream-name"),
"Puppeteer",
);
assert.ok(
(await common.get_text_from_selector(page, hidden_streams_selector)).includes("Verona"),
"#Verona is not hidden",
);
assert.ok(
!(await common.get_text_from_selector(page, hidden_streams_selector)).includes("Puppeteer"),
"Puppeteer is hidden after searching.",
);
}
async function subscriptions_tests(page: Page): Promise<void> {
await common.log_in(page);
await common.open_streams_modal(page);
await test_stream_creation(page);
await test_streams_search_feature(page);
}
await common.run_test(subscriptions_tests);

View File

@@ -0,0 +1,57 @@
import type {ElementHandle, Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function test_subscription_button(page: Page): Promise<void> {
const all_stream_selector = "[data-tab-key='all']";
await page.waitForSelector(all_stream_selector, {visible: true});
await page.click(all_stream_selector);
const stream_selector = "[data-stream-name='Venice']";
const button_selector = `${stream_selector} .sub_unsub_button`;
const subscribed_selector = `${button_selector}.checked`;
const unsubscribed_selector = `${button_selector}:not(.checked)`;
async function subscribed(): Promise<ElementHandle | null> {
await page.waitForSelector(
`xpath///*[${common.has_class_x("stream-info-title")}]//*[${common.has_class_x(
"sub_unsub_button",
)} and normalize-space()="Unsubscribe"]`,
{visible: true},
);
return await page.waitForSelector(subscribed_selector, {visible: true});
}
async function unsubscribed(): Promise<ElementHandle | null> {
await page.waitForSelector(
`xpath///*[${common.has_class_x("stream-info-title")}]//*[${common.has_class_x(
"sub_unsub_button",
)} and normalize-space()="Subscribe"]`,
{visible: true},
);
return await page.waitForSelector(unsubscribed_selector, {visible: true});
}
// Make sure that Venice is even in our list of streams.
await page.waitForSelector(stream_selector, {visible: true});
await page.waitForSelector(button_selector, {visible: true});
await page.click(stream_selector);
// Note that we intentionally re-find the button after each click, since
// the live-update code may replace the whole row.
// We assume Venice is already subscribed, so the first line here
// should happen immediately.
await (await subscribed())!.click();
await (await unsubscribed())!.click();
await (await subscribed())!.click();
await (await unsubscribed())!.click();
await subscribed();
}
async function subscriptions_tests(page: Page): Promise<void> {
await common.log_in(page);
await common.open_streams_modal(page);
await test_subscription_button(page);
}
await common.run_test(subscriptions_tests);

View File

@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function navigate_to_user_list(page: Page): Promise<void> {
const menu_selector = "#settings-dropdown";
await page.waitForSelector(menu_selector, {visible: true});
await page.click(menu_selector);
const organization_settings = '.link-item a[href="#organization"]';
await page.waitForSelector(organization_settings, {visible: true});
await page.click(organization_settings);
await page.waitForSelector("#settings_overlay_container.show", {visible: true});
await page.waitForSelector("li[data-section='users']", {visible: true});
await page.click("li[data-section='users']");
await page.waitForSelector("#admin-user-list.show", {visible: true});
}
async function user_row(page: Page, name: string): Promise<string> {
const user_id = await common.get_user_id_from_name(page, name);
assert.ok(user_id !== undefined);
return `.user_row[data-user-id="${CSS.escape(user_id.toString())}"]`;
}
async function test_reactivation_confirmation_modal(page: Page, fullname: string): Promise<void> {
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Reactivate " + fullname,
"Unexpected title for reactivate user modal",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
"Confirm",
"Reactivate button has incorrect text.",
);
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
}
async function test_deactivate_user(page: Page): Promise<void> {
const cordelia_user_row = await user_row(page, common.fullname.cordelia);
await page.waitForSelector(cordelia_user_row, {visible: true});
// Wait for the presence-data fetch in `settings_users.populate_users`
// to complete and the table to re-render.
await page.waitForSelector(cordelia_user_row + " .loading-placeholder", {hidden: true});
await page.waitForSelector(cordelia_user_row + " .zulip-icon-user-x", {visible: true});
await page.click(cordelia_user_row + " .deactivate");
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Deactivate " + common.fullname.cordelia + "?",
"Unexpected title for deactivate user modal",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
"Deactivate",
"Deactivate button has incorrect text.",
);
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
}
async function test_reactivate_user(page: Page): Promise<void> {
let cordelia_user_row = await user_row(page, common.fullname.cordelia);
await page.waitForSelector(cordelia_user_row + ".deactivated_user");
await page.waitForSelector(cordelia_user_row + " .zulip-icon-user-plus", {visible: true});
await page.click(cordelia_user_row + " .reactivate");
await test_reactivation_confirmation_modal(page, common.fullname.cordelia);
await page.waitForSelector(cordelia_user_row + ":not(.deactivated_user)", {visible: true});
cordelia_user_row = await user_row(page, common.fullname.cordelia);
await page.waitForSelector(cordelia_user_row + " .zulip-icon-user-x", {visible: true});
}
async function test_deactivated_users_section(page: Page): Promise<void> {
const cordelia_user_row = await user_row(page, common.fullname.cordelia);
await test_deactivate_user(page);
// "Deactivated users" section doesn't render just deactivated users until reloaded.
await page.reload();
await page.waitForSelector("#admin-user-list.show", {visible: true});
const deactivated_users_section = ".tab-container .ind-tab[data-tab-key='deactivated']";
await page.waitForSelector(deactivated_users_section, {visible: true});
await page.click(deactivated_users_section);
// Instead of waiting for reactivate button using the `waitForSelector` function,
// we wait until the input is focused because the `waitForSelector` function
// doesn't guarantee that element is interactable.
await page.waitForSelector("input[aria-label='Filter deactivated users']", {visible: true});
await page.click("input[aria-label='Filter deactivated users']");
await page.waitForFunction(
() => document.activeElement?.classList?.contains("search") === true,
);
await page.click("#admin_deactivated_users_table " + cordelia_user_row + " .reactivate");
await test_reactivation_confirmation_modal(page, common.fullname.cordelia);
await page.waitForSelector(
"#admin_deactivated_users_table " + cordelia_user_row + " button:not(.reactivate)",
{visible: true},
);
}
async function test_bot_deactivation_and_reactivation(page: Page): Promise<void> {
await page.waitForSelector(".org-settings-list li[data-section='bots']", {visible: true});
await page.click(".org-settings-list li[data-section='bots']");
await page.waitForSelector("#admin-bot-list.show", {visible: true});
const default_bot_user_row = await user_row(page, "Zulip Default Bot");
await page.waitForSelector(default_bot_user_row + " .deactivate", {visible: true});
await page.click(default_bot_user_row + " .deactivate");
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
"Deactivate Zulip Default Bot?",
"Unexpected title for deactivate bot modal",
);
assert.strictEqual(
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
"Deactivate",
"Deactivate button has incorrect text.",
);
await page.click(".micromodal .dialog_submit_button");
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(default_bot_user_row + ".deactivated_user", {visible: true});
await page.waitForSelector(default_bot_user_row + " .zulip-icon-user-plus", {visible: true});
await page.click(default_bot_user_row + " .reactivate");
await test_reactivation_confirmation_modal(page, "Zulip Default Bot");
await page.waitForSelector(default_bot_user_row + ":not(.deactivated_user)", {visible: true});
await page.waitForSelector(default_bot_user_row + " .zulip-icon-user-x", {visible: true});
}
async function user_deactivation_test(page: Page): Promise<void> {
await common.log_in(page);
await navigate_to_user_list(page);
await test_deactivate_user(page);
await test_reactivate_user(page);
await test_deactivated_users_section(page);
await test_bot_deactivation_and_reactivation(page);
}
await common.run_test(user_deactivation_test);

View File

@@ -0,0 +1,58 @@
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function open_set_user_status_modal(page: Page): Promise<void> {
await page.click("#personal-menu");
await page.waitForSelector("#personal-menu-dropdown", {visible: true});
// We are using evaluate to click because it is very hard to detect if
// the personal menu popover has opened.
await page.evaluate(() => {
document.querySelector<HTMLAnchorElement>(".update_status_text")!.click();
});
// Wait for the modal to completely open.
await common.wait_for_micromodal_to_open(page);
}
async function test_user_status(page: Page): Promise<void> {
await open_set_user_status_modal(page);
// Check by clicking on common statues.
await page.click(".user-status-option:nth-child(2) .user-status-value");
await page.waitForFunction(
() => document.querySelector<HTMLInputElement>(".user-status")!.value === "In a meeting",
);
// It should select calendar emoji.
await page.waitForSelector(".selected-emoji.emoji-1f4c5");
// Clear everything.
await page.click("#clear_status_message_button");
await page.waitForFunction(
() => document.querySelector<HTMLInputElement>(".user-status")!.value === "",
);
await page.waitForSelector(".status-emoji-wrapper .smiley-icon", {visible: true});
// Manually adding everything.
await page.type(".user-status", "Busy");
const laughing_emoji_selector = ".emoji-1f606";
await page.click(".status-emoji-wrapper .smiley-icon");
// Wait until emoji popover is opened.
await page.waitForSelector(`.emoji-popover ${laughing_emoji_selector}`, {visible: true});
await page.click(`.emoji-popover ${laughing_emoji_selector}`);
await page.waitForSelector(".emoji-picker-popover", {hidden: true});
await page.waitForSelector(`.selected-emoji${laughing_emoji_selector}`);
await page.click("#set-user-status-modal .dialog_submit_button");
// It should close the modal after saving.
await page.waitForSelector("#set-user-status-modal", {hidden: true});
// Check if the emoji is added in user presence list.
await page.waitForSelector(`.user-presence-link .status-emoji${laughing_emoji_selector}`);
}
async function user_status_test(page: Page): Promise<void> {
await common.log_in(page);
await test_user_status(page);
}
await common.run_test(user_status_test);