discourse/plugins/discourse-ai/test/javascripts/acceptance/post-helper-menu-test.js
Renato Atilio d62cc48d9c
DEV: to-markdown prosemirror HTML parsing (#35563)
Replaces the custom ~1000-line `to-markdown` converter with ProseMirror's schema-based parsing and serialization pipeline, reusing the same extensions that power the rich editor. The old converter duplicated logic the extensions already implement (image formatting, mention/hashtag serialization, table handling, list nesting, etc.) — a single pipeline means one set of rules, fewer divergence bugs, and plugin extensions work in both directions automatically.

### Core change

- `toMarkdown()` is now async and lazy-loads ProseMirror on first use.
- Cooked→markdown serialization runs through the registered rich-editor extensions, so both conversion directions share one source of truth.

### QuoteState and quote callers

Making `toMarkdown()` async changed the quote flow. `QuoteState` now stores the selection's HTML and exposes an async `markdown()`; `buffer` is the plain-text selection. Every caller that needs markdown was updated to await `markdown()`:

- Topic controller (`selectText`, `replyToPost` including EmbedMode, `replyAsNewTopic`, `buildQuoteMarkdown`).
- Nested controller — forwards the full selection state via the new `copyFrom`.
- Fast-edit modal and `computeSupportsFastEdit`.
- discourse-ai post-helper menu — now sends markdown (not plain text) to the AI endpoints.

`markdown()` snapshots `opts` before awaiting so a concurrent selection change can't mis-pair the result.

### Plugin API

- Removed plugin-specific `addTagDecorateCallback`/`addTextDecorateCallback` usage from local-dates and spoiler-alert (the old APIs are kept as deprecated no-ops). local-dates now uses a `transformParsedHTML` rich-editor extension hook.

### Extractions and sharing

- Word paste handling moved into a dedicated `word-paste.js` extension.
- Quote-selection list-structure preservation moved into `preserve-list-structure.js`.
- `normalizeTable` is shared between the paste plugin and the serializer for consistent table output across editor modes.

### Quoting fix

- `selectedHTML` drops the empty trailing block a triple-click leaves behind. A triple-click extends the selection's end to the start of the following block, so `cloneContents()` would otherwise clone an empty `<blockquote>` that serializes to a stray `> ` in the quote.
2026-05-29 18:14:32 -03:00

138 lines
4.7 KiB
JavaScript
Vendored

import { click, settled, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { AUTO_GROUPS } from "discourse/lib/constants";
import { cloneJSON } from "discourse/lib/object";
import topicFixtures from "discourse/tests/fixtures/topic";
import {
acceptance,
publishToMessageBus,
query,
selectText,
} from "discourse/tests/helpers/qunit-helpers";
import aiHelperPrompts from "../fixtures/ai-helper-prompts";
let lastStreamRequest = null;
acceptance("Post Helper Menu", function (needs) {
needs.settings({
discourse_ai_enabled: true,
ai_helper_enabled: true,
post_ai_helper_allowed_groups: "1|2",
ai_helper_enabled_features: "suggestions|context_menu",
share_quote_visibility: "anonymous",
enable_markdown_footnotes: true,
display_footnotes_inline: true,
});
needs.user({
admin: true,
moderator: true,
groups: [AUTO_GROUPS.admins],
can_use_assistant_in_post: true,
ai_helper_prompts: aiHelperPrompts,
trust_level: 4,
});
needs.pretender((server, helper) => {
server.get("/t/1.json", () => {
const json = cloneJSON(topicFixtures["/t/28830/1.json"]);
json.post_stream.posts[0].can_edit_post = true;
json.post_stream.posts[0].can_edit = true;
return helper.response(json);
});
server.get("/t/2.json", () => {
const json = cloneJSON(topicFixtures["/t/28830/1.json"]);
json.post_stream.posts[0].cooked =
"<p>La lluvia en España se queda principalmente en el avión.</p>";
return helper.response(json);
});
server.get("/t/3.json", () => {
const json = cloneJSON(topicFixtures["/t/28830/1.json"]);
json.post_stream.posts[0].cooked =
"<p>plain start <b>BOLD MIDDLE</b> plain end</p>";
return helper.response(json);
});
server.post(`/discourse-ai/ai-helper/stream_suggestion/`, (request) => {
lastStreamRequest = Object.fromEntries(
new URLSearchParams(request.requestBody)
);
return helper.response({
result: "This is a suggestio",
done: false,
progress_channel: "/some/progress/channel",
});
});
server.get("/discourse-ai/ai-bot/conversations.json", () => {});
});
test("displays streamed explanation", async function (assert) {
await visit("/t/-/1");
const suggestion = "This is a suggestion that is completed";
const textNode = query("#post_1 .cooked p").childNodes[0];
await selectText(textNode, 9);
await click(".ai-post-helper__trigger");
await click(".ai-helper-options__button[data-name='explain']");
await publishToMessageBus(`/some/progress/channel`, {
done: true,
result: suggestion,
});
assert.dom(".ai-post-helper__suggestion__text").hasText(suggestion);
});
async function selectSpecificText(textNode, start, end) {
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, end);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const event = new PointerEvent("pointerup");
document.dispatchEvent(event);
await settled();
}
test("adds explained text as footnote to post", async function (assert) {
await visit("/t/-/1");
const suggestion = "This is a suggestion that is completed";
const textNode = query("#post_1 .cooked p").childNodes[0];
await selectSpecificText(textNode, 72, 77);
await click(".ai-post-helper__trigger");
await click(".ai-helper-options__button[data-name='explain']");
await publishToMessageBus(`/some/progress/channel`, {
done: true,
result: suggestion,
});
assert.dom(".ai-post-helper__suggestion__insert-footnote").isDisabled();
});
test("shows translated post", async function (assert) {
await visit("/t/-/2");
const translated = "The rain in Spain, stays mainly in the Plane.";
await selectText(query("#post_1 .cooked p"));
await click(".ai-post-helper__trigger");
await click(".ai-helper-options__button[data-name='translate']");
await publishToMessageBus(`/some/progress/channel`, {
done: true,
result: translated,
});
assert.dom(".ai-post-helper__suggestion__text").hasText(translated);
});
test("AI helper request sends the markdown form, not plaintext", async function (assert) {
lastStreamRequest = null;
await visit("/t/-/3");
await selectText(query("#post_1 .cooked p"));
await click(".ai-post-helper__trigger");
await click(".ai-helper-options__button[data-name='translate']");
assert.true(
lastStreamRequest?.text?.includes("**BOLD MIDDLE**"),
`payload should contain markdown formatting (got: ${lastStreamRequest?.text})`
);
});
});