snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
276
web/e2e-tests/admin.test.ts
Normal file
276
web/e2e-tests/admin.test.ts
Normal 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);
|
||||
243
web/e2e-tests/compose.test.ts
Normal file
243
web/e2e-tests/compose.test.ts
Normal 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> >> 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);
|
||||
313
web/e2e-tests/copy-messages.test.ts
Normal file
313
web/e2e-tests/copy-messages.test.ts
Normal 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);
|
||||
97
web/e2e-tests/custom-profile.test.ts
Normal file
97
web/e2e-tests/custom-profile.test.ts
Normal 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);
|
||||
46
web/e2e-tests/delete-message.test.ts
Normal file
46
web/e2e-tests/delete-message.test.ts
Normal 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);
|
||||
329
web/e2e-tests/drafts.test.ts
Normal file
329
web/e2e-tests/drafts.test.ts
Normal 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
117
web/e2e-tests/edit.test.ts
Normal 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
756
web/e2e-tests/lib/common.ts
Normal 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;
|
||||
}
|
||||
268
web/e2e-tests/mark-messages-read-near.test.ts
Normal file
268
web/e2e-tests/mark-messages-read-near.test.ts
Normal 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);
|
||||
49
web/e2e-tests/mention.test.ts
Normal file
49
web/e2e-tests/mention.test.ts
Normal 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);
|
||||
538
web/e2e-tests/message-basics.test.ts
Normal file
538
web/e2e-tests/message-basics.test.ts
Normal 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);
|
||||
119
web/e2e-tests/navigation.test.ts
Normal file
119
web/e2e-tests/navigation.test.ts
Normal 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);
|
||||
90
web/e2e-tests/realm-creation.test.ts
Normal file
90
web/e2e-tests/realm-creation.test.ts
Normal 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);
|
||||
168
web/e2e-tests/realm-linkifier.test.ts
Normal file
168
web/e2e-tests/realm-linkifier.test.ts
Normal 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);
|
||||
109
web/e2e-tests/realm-playground.test.ts
Normal file
109
web/e2e-tests/realm-playground.test.ts
Normal 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);
|
||||
531
web/e2e-tests/settings.test.ts
Normal file
531
web/e2e-tests/settings.test.ts
Normal 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);
|
||||
86
web/e2e-tests/stars.test.ts
Normal file
86
web/e2e-tests/stars.test.ts
Normal 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);
|
||||
191
web/e2e-tests/stream_create.test.ts
Normal file
191
web/e2e-tests/stream_create.test.ts
Normal 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);
|
||||
57
web/e2e-tests/subscribe_toggle.test.ts
Normal file
57
web/e2e-tests/subscribe_toggle.test.ts
Normal 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);
|
||||
153
web/e2e-tests/user-deactivation.test.ts
Normal file
153
web/e2e-tests/user-deactivation.test.ts
Normal 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);
|
||||
58
web/e2e-tests/user-status.test.ts
Normal file
58
web/e2e-tests/user-status.test.ts
Normal 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);
|
||||
Reference in New Issue
Block a user