"use strict"; const assert = require("node:assert/strict"); const {JSDOM} = require("jsdom"); const katex_tests = require("../../zerver/tests/fixtures/katex_test_cases.json"); const {parse} = require("../src/markdown.ts"); const {make_stream} = require("./lib/example_stream.cjs"); const {mock_esm, zrequire, set_global} = require("./lib/namespace.cjs"); const {run_test, noop} = require("./lib/test.cjs"); const $ = require("./lib/zjquery.cjs"); const {window} = new JSDOM(); const text_field_edit = mock_esm("text-field-edit", {insertTextIntoField: noop}); const compose_paste = zrequire("compose_paste"); const compose_ui = zrequire("compose_ui"); const linkifiers = zrequire("linkifiers"); const markdown = zrequire("markdown"); const markdown_config = zrequire("markdown_config"); const stream_data = zrequire("stream_data"); const {initialize_user_settings} = zrequire("user_settings"); set_global("document", {}); class ClipboardEvent { constructor({clipboardData} = {}) { this.clipboardData = clipboardData; } } set_global("ClipboardEvent", ClipboardEvent); initialize_user_settings({ user_settings: { translate_emoticons: false, }, }); markdown.initialize(markdown_config.get_helpers()); stream_data.add_sub_for_tests( make_stream({ stream_id: 4, name: "Rome", }), ); stream_data.add_sub_for_tests( make_stream({ stream_id: 5, name: "Romeo`s lair", }), ); run_test("try_stream_topic_syntax_text", () => { const test_cases = [ [ "http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT", "#**Rome>old FAILED EXPORT**", ], [ "http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/100.25.20profits", "#**Rome>100% profits**", ], [ "http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20API.20wasn't.20compiling.20erratically", "#**Rome>old API wasn't compiling erratically**", ], ["http://different.origin.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT"], [ "http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT/near/100", "#**Rome>old FAILED EXPORT@100**", ], ["http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic//near/100", "#**Rome>@100**"], [ "http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT/with/100", "#**Rome>old FAILED EXPORT**", ], // malformed urls ["http://zulip.zulipdev.com/narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT"], ["http://zulip.zulipdev.com/#not_narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT"], ["http://zulip.zulipdev.com/#narrow/not_stream/4-Rome/topic/old.20FAILED.20EXPORT"], ["http://zulip.zulipdev.com/#narrow/channel/4-Rome/not_topic/old.20FAILED.20EXPORT"], ["http://zulip.zulipdev.com/#narrow/channel/4-Rome/", "#**Rome**"], ["http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic"], ["http://zulip.zulipdev.com/#narrow/topic/cheese"], ["http://zulip.zulipdev.com/#narrow/topic/pizza/stream/Rome"], ["http://zulip.zulipdev.com/#narrow/channel/4-Rome/topic/old.20FAILED.20EXPORT/near/"], // When a url containing characters which are known to produce broken // #**stream>topic** urls is pasted, a normal markdown link syntax is produced. [ "http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20profits.60", "[#Rome > 100% profits`](#narrow/channel/4-Rome/topic/100.25.20profits.60)", ], [ "http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20*profits", "[#Rome > 100% *profits](#narrow/channel/4-Rome/topic/100.25.20.2Aprofits)", ], [ "http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/.24.24 100.25.20profits", "[#Rome > $$ 100% profits](#narrow/channel/4-Rome/topic/.24.24.20100.25.20profits)", ], [ "http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/>100.25.20profits", "[#Rome > >100% profits](#narrow/channel/4-Rome/topic/.3E100.25.20profits)", ], [ "http://zulip.zulipdev.com/#narrow/stream/5-Romeo.60s-lair/topic/normal", "[#Romeo`s lair > normal](#narrow/channel/5-Romeo.60s-lair/topic/normal)", ], [ "http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20profits.60/near/20", "[#Rome > 100% profits` @ 💬](#narrow/channel/4-Rome/topic/100.25.20profits.60/near/20)", ], ]; for (const test_case of test_cases) { const result = compose_paste.try_stream_topic_syntax_text(test_case[0]); const expected = test_case[1] ?? null; assert.equal(result, expected, "Failed for url: " + test_case[0]); } }); run_test("maybe_transform_html", () => { // Copied HTML from VS Code let paste_html = `
if ($preview_src.endsWith("&size=full"))
`; let paste_text = `if ($preview_src.endsWith("&size=full"))`; const escaped_paste_text = "if ($preview_src.endsWith("&size=full"))"; const expected_output = "
" + escaped_paste_text + "
"; assert.equal(compose_paste.maybe_transform_html(paste_html, paste_text), expected_output); // Untransformed HTML paste_html = "
Hello
World!
"; paste_text = "Hello\nWorld!"; assert.equal(compose_paste.maybe_transform_html(paste_html, paste_text), paste_html); }); run_test("paste_handler reverse linkify", ({override, override_rewire}) => { global.document = window.document; global.window = window; global.Node = window.Node; global.HTMLElement = window.HTMLElement; global.HTMLAnchorElement = window.HTMLAnchorElement; global.HTMLTextAreaElement = window.HTMLTextAreaElement; linkifiers.update_linkifier_rules([ { id: 1, pattern: "#D(?P[0-9]{2,8})", url_template: "https://github.com/zulip/zulip-desktop/pull/{id}", reverse_template: "#D{id}", alternative_url_templates: ["https://github.com/zulip/zulip-desktop/issues/{id}"], }, ]); let inserted_text; let undo_texts; override_rewire(compose_ui, "insert_and_scroll_into_view", (text) => { inserted_text = text; }); override(text_field_edit, "insertTextIntoField", (_textarea, text) => { undo_texts.push(text); }); const html_with_formatting = `

x + y 2 x + y2 test https://github.com/zulip/zulip-desktop/pull/1359

`; const test_cases = [ { // Reverse linkify should preserve formatting when pasting HTML. paste_html: html_with_formatting, paste_text: "x\n+\ny\n2\nx + y2\nx+y2 test https://github.com/zulip/zulip-desktop/pull/1359", expected: "$$x + y2$$ ~~test~~ #D1359", // When paste_html is a real value, there are two undo states: // first undo reverses the reverse linkify (back to formatted text), // second undo reverses the formatting (back to plain text). expected_undo_texts: [ "x\n+\ny\n2\nx + y2\nx+y2 test https://github.com/zulip/zulip-desktop/pull/1359", "$$x + y2$$ ~~test~~ https://github.com/zulip/zulip-desktop/pull/1359", ], }, { // Reverse linkify should work for URL only text. paste_html: "", paste_text: "https://github.com/zulip/zulip-desktop/pull/1359", expected: "#D1359", expected_undo_texts: ["https://github.com/zulip/zulip-desktop/pull/1359"], }, { // Reverse linkify should work in plain text with surrounding text. paste_html: "", paste_text: "https://github.com/zulip/zulip-desktop/pull/1359 dummy text.", expected: "#D1359 dummy text.", expected_undo_texts: ["https://github.com/zulip/zulip-desktop/pull/1359 dummy text."], }, { // Reverse linkify should work for alternative URL templates. paste_html: "", paste_text: "https://github.com/zulip/zulip-desktop/issues/42", expected: "#D42", expected_undo_texts: ["https://github.com/zulip/zulip-desktop/issues/42"], }, ]; for (const test_case of test_cases) { const $textarea = $("textarea#compose-textarea"); // Put the cursor at the start with no selected text. // The URL paste path checks this before it reaches reverse-linkify. $textarea[0] = window.document.createElement("textarea"); $textarea[0].value = ""; inserted_text = undefined; undo_texts = []; const event = { originalEvent: new ClipboardEvent({ clipboardData: { getData(format) { if (format === "text/html") { return test_case.paste_html; } return test_case.paste_text; }, }, }), preventDefault() {}, stopPropagation() {}, }; compose_paste.paste_handler.call($textarea, event, noop); assert.equal(inserted_text, test_case.expected, test_case.paste_text); assert.deepEqual( undo_texts, test_case.expected_undo_texts, `undo texts for: ${test_case.paste_text}`, ); } }); run_test("paste_handler_converter", () => { /* Pasting from another Zulip message */ global.document = window.document; global.window = window; global.Node = window.Node; global.HTMLElement = window.HTMLElement; global.HTMLAnchorElement = window.HTMLAnchorElement; // Bold text let input = ' love the Zulip Organization.'; assert.equal( compose_paste.paste_handler_converter(input), " love the **Zulip** **Organization**.", ); // Inline code input = 'The JSDOM constructor'; assert.equal(compose_paste.paste_handler_converter(input), "The `JSDOM` constructor"); // A python code block input = `

zulip code block in python

print("hello")\nprint("world")
`; assert.equal( compose_paste.paste_handler_converter(input), 'zulip code block in python\n\n```Python\nprint("hello")\nprint("world")\n```', ); // Single line in a code block input = '
single line
'; assert.equal(compose_paste.paste_handler_converter(input), "`single line`"); // No code formatting if the given text area has a backtick at the cursor position input = '
single line
'; assert.equal( compose_paste.paste_handler_converter(input, { val() { return "e.g. `"; }, 0: { selectionStart: 6, value: "e.g. `", }, length: 1, }), "single line", ); // No code formatting if the given text area has a backtick at the cursor position input = '
single line
'; assert.equal( compose_paste.paste_handler_converter(input, { val() { return "`e.g. ` ```hi`` ``"; }, 0: { selectionStart: 19, value: "`e.g. ` ```hi`` ``", }, length: 1, }), "single line", ); // No code formatting if the given text area has a opening backtick before the cursor position input = '
single line
'; assert.equal( compose_paste.paste_handler_converter(input, { val() { return "e.g. ` "; }, 0: { selectionStart: 7, value: "e.g. ` ", }, length: 1, }), "single line", ); // Yes code formatting if the given text area does not have a backtick at the cursor position. input = '
single line
'; assert.equal( compose_paste.paste_handler_converter(input, { val() { return ""; }, 0: { selectionStart: 0, value: "", }, length: 1, }), "`single line`", ); // Yes code formatting if the given text area closes the code block before the cursor position input = '
single line
'; assert.equal( compose_paste.paste_handler_converter(input, { val() { return "` e.g. ` "; }, 0: { selectionStart: 9, value: "` e.g. ` ", }, length: 1, }), "`single line`", ); // Yes code formatting if the given text area closes the code block before the cursor position input = '
single line
'; assert.equal( compose_paste.paste_handler_converter(input, { val() { return "``` e.g. ``` ``hi`` "; }, 0: { selectionStart: 20, value: "``` e.g. ``` ``hi`` ", }, length: 1, }), "`single line`", ); // Raw links without custom text input = 'https://zulip.readthedocs.io/en/latest/subsystems/logging.html'; assert.equal( compose_paste.paste_handler_converter(input), "https://zulip.readthedocs.io/en/latest/subsystems/logging.html", ); // Links with custom text input = 'Contributing guide'; assert.equal( compose_paste.paste_handler_converter(input), "[Contributing guide](https://zulip.readthedocs.io/en/latest/contributing/contributing.html)", ); // Only numbered list (list style retained) input = '
  1. text
'; assert.equal(compose_paste.paste_handler_converter(input), "1. text"); // Heading input = '

Zulip overview

normal text

'; assert.equal(compose_paste.paste_handler_converter(input), "# Zulip overview\n\nnormal text"); // Only heading (strip heading style) input = '

Zulip overview

'; assert.equal(compose_paste.paste_handler_converter(input), "Zulip overview"); // Italic text input = 'normal text This text is italic'; assert.equal(compose_paste.paste_handler_converter(input), "normal text *This text is italic*"); // Strikethrough text input = 'normal text This text is struck through'; assert.equal( compose_paste.paste_handler_converter(input), "normal text ~~This text is struck through~~", ); // Emojis input = 'emojis: :smile: :family_man_woman_girl:'; assert.equal( compose_paste.paste_handler_converter(input), "emojis: :smile: :family_man_woman_girl:", ); // Nested lists input = ''; assert.equal( compose_paste.paste_handler_converter(input), "* bulleted\n* nested\n * nested level 1\n * nested level 1 continue\n * nested level 2\n * nested level 2 continue", ); // Heading from https://arxiv.org/abs/1301.3191 input = '

Enriched categories as a free cocompletion


'; assert.equal( compose_paste.paste_handler_converter(input), "Enriched categories as a free cocompletion", ); // Heading from https://www.sciencedirect.com/science/article/pii/S0001870815004715 input = '

Abstract


'; assert.equal(compose_paste.paste_handler_converter(input), "Abstract"); // Heading from https://en.wikipedia.org/wiki/James_Madison input = '

James Madison


'; assert.equal(compose_paste.paste_handler_converter(input), "James Madison"); // Heading from https://customer-identity-access-management.hashnode.dev/from-words-to-vectors-understanding-the-magic-of-text-embedding input = `

From Words to Vectors: Understanding the Magic of Text Embedding

`; assert.equal( compose_paste.paste_handler_converter(input), "From Words to Vectors: Understanding the Magic of Text Embedding", ); // Check we don't double-convert HTML to text. input = `turtles are cool`; assert.equal(compose_paste.paste_handler_converter(input), "turtles are cool"); input = `<del>turtles are cool</del>`; assert.equal(compose_paste.paste_handler_converter(input), "turtles are cool"); // 2 paragraphs with line break/s in between input = '

paragraph 1


paragraph 2

'; assert.equal(compose_paste.paste_handler_converter(input), "paragraph 1\n\nparagraph 2"); // Pasting from external sources // Pasting list from GitHub input = '

Test list:

  • Item 1
  • Item 2
'; assert.equal(compose_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2"); // Pasting list from VS Code input = '
Test list:
  • Item 1
  • Item 2
'; assert.equal(compose_paste.paste_handler_converter(input), "Test list:\n* Item 1\n* Item 2"); // Pasting markdown from VS Code where each line is wrapped by a
shouldn't insert an extra newline. input = `\r\n\r\n
authentication-methods
export-and-import
postgresql
upload-backends
ssl-certificates
email
\r\n\r\n`; assert.equal( compose_paste.paste_handler_converter(input), "authentication-methods\nexport-and-import\npostgresql\nupload-backends\nssl-certificates\nemail", ); // Pasting from Google Sheets (remove 123'; assert.equal(compose_paste.paste_handler_converter(input), "123"); // Pasting a long, visually line-wrapped single-line message from Firefox should not insert extraneous newlines. input = `\n
\n

At some point recently, Zulip changed such that copying a \nlong message includes hard newlines, rather than putting things all on \none line when they were on one line in the original message.

\n
\n\n`; assert.equal( compose_paste.paste_handler_converter(input), "At some point recently, Zulip changed such that copying a long message includes hard newlines, rather than putting things all on one line when they were on one line in the original message.", ); // Pasting from Excel input = `\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n\n
$\n 20.00
$ 7.00
\n\n`; // Pasting from Excel using ^V should paste an image. assert.ok(compose_paste.is_single_image(input)); // Pasting from Excel using ^⇧V should paste formatted text. assert.equal(compose_paste.paste_handler_converter(input), " \n\n$ 20.00\n\n$ 7.00"); // Pasting from LibreOffice Calc should paste an image. input = `
KathleenHannerFemaleUnited States
NereidaMagwoodFemaleUnited States
`; assert.ok(compose_paste.is_single_image(input)); // This contains three child elements inside the body tag, pasted // from LibreOffice Writer, which is correctly classified as not an image. input = `

ello world

X

as

Jak

J

Nm

,mn

,nnf

Adlk

Asn

,amns

Nm

Oi

Poi

B

Ijo

,mn,

;ih

Oug

Iu

G

Ug

Bkjb

Kjbk

;jbj

;jb;

Bkjb

Ugug

I9

68

0

90kjb

,bnbiu

Ofif

P8gp

pugp


`; assert.ok(!compose_paste.is_single_image(input)); // has a single child element which is not a pasted // from LibreOffice Writer should get pasted normally. input = `

Hello world this is some random text.

`; assert.ok(!compose_paste.is_single_image(input)); // A single table pasted from LibreOffice Writer is incorrectly // detected as a LibreOffice Calc table. // See https://github.com/zulip/zulip/pull/34752/#discussion_r2113598064 input = `

Melgar

Female

UnitedStates

Weiland

Female

UnitedStates

Winward

Female

GreatBritain

`; assert.ok(compose_paste.is_single_image(input)); // Copying an image and pasting it with `paste_html` containing other empty text nodes and // comment nodes should still classify the `paste_html` as an image that needs to be uploaded. input = `\n\n\n\n`; assert.ok(compose_paste.is_single_image(input)); // Pasting from the mac terminal input = '

insertions

'; assert.equal(compose_paste.paste_handler_converter(input), "insertions"); // Zulip timestamp followed by text. input = ' good better '; assert.equal( compose_paste.paste_handler_converter(input), " good better", ); // Chrome-stripped timestamp: Chrome's clipboard serializer removes the