diff --git a/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml b/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml index 591148b41ddb3b069f137c28ac851d7ed26e2e30..b09a092c02a503eec6c154bb9468bb96aaa571a5 100644 --- a/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml +++ b/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml @@ -10,5 +10,5 @@ skip_running_conformance_static_tests: false # NOT YET SUPPORTED skip_running_conformance_wysiwyg_tests: false # NOT YET SUPPORTED skip_running_snapshot_static_html_tests: false # NOT YET SUPPORTED - skip_running_snapshot_wysiwyg_html_tests: false # NOT YET SUPPORTED - skip_running_snapshot_prosemirror_json_tests: false # NOT YET SUPPORTED + skip_running_snapshot_wysiwyg_html_tests: false + skip_running_snapshot_prosemirror_json_tests: false diff --git a/scripts/lib/glfm/render_wysiwyg_html_and_json.js b/scripts/lib/glfm/render_wysiwyg_html_and_json.js index 58b440d7ab2661ad87683513b2ce4a7869ac27bc..ed8bfdb463874cc2bbe862f39c1127c545d4795e 100644 --- a/scripts/lib/glfm/render_wysiwyg_html_and_json.js +++ b/scripts/lib/glfm/render_wysiwyg_html_and_json.js @@ -1,117 +1,7 @@ import fs from 'fs'; -import { DOMSerializer } from 'prosemirror-model'; import jsYaml from 'js-yaml'; -// TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js -// See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan -import Blockquote from '~/content_editor/extensions/blockquote'; -import Bold from '~/content_editor/extensions/bold'; -import BulletList from '~/content_editor/extensions/bullet_list'; -import Code from '~/content_editor/extensions/code'; -import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import DescriptionItem from '~/content_editor/extensions/description_item'; -import DescriptionList from '~/content_editor/extensions/description_list'; -import Details from '~/content_editor/extensions/details'; -import DetailsContent from '~/content_editor/extensions/details_content'; -import Division from '~/content_editor/extensions/division'; -import Emoji from '~/content_editor/extensions/emoji'; -import Figure from '~/content_editor/extensions/figure'; -import FigureCaption from '~/content_editor/extensions/figure_caption'; -import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; -import FootnoteReference from '~/content_editor/extensions/footnote_reference'; -import FootnotesSection from '~/content_editor/extensions/footnotes_section'; -import HardBreak from '~/content_editor/extensions/hard_break'; -import Heading from '~/content_editor/extensions/heading'; -import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; -import Image from '~/content_editor/extensions/image'; -import InlineDiff from '~/content_editor/extensions/inline_diff'; -import Italic from '~/content_editor/extensions/italic'; -import Link from '~/content_editor/extensions/link'; -import ListItem from '~/content_editor/extensions/list_item'; -import OrderedList from '~/content_editor/extensions/ordered_list'; -import Strike from '~/content_editor/extensions/strike'; -import Table from '~/content_editor/extensions/table'; -import TableCell from '~/content_editor/extensions/table_cell'; -import TableHeader from '~/content_editor/extensions/table_header'; -import TableRow from '~/content_editor/extensions/table_row'; -import TaskItem from '~/content_editor/extensions/task_item'; -import TaskList from '~/content_editor/extensions/task_list'; -import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; -import { createTestEditor } from 'jest/content_editor/test_utils'; import { setTestTimeout } from 'jest/__helpers__/timeout'; - -const tiptapEditor = createTestEditor({ - extensions: [ - Blockquote, - Bold, - BulletList, - Code, - CodeBlockHighlight, - DescriptionItem, - DescriptionList, - Details, - DetailsContent, - Division, - Emoji, - FootnoteDefinition, - FootnoteReference, - FootnotesSection, - Figure, - FigureCaption, - HardBreak, - Heading, - HorizontalRule, - Image, - InlineDiff, - Italic, - Link, - ListItem, - OrderedList, - Strike, - Table, - TableCell, - TableHeader, - TableRow, - TaskItem, - TaskList, - ], -}); - -async function renderMarkdownToHTMLAndJSON(markdown, schema, deserializer) { - let prosemirrorDocument; - try { - const { document } = await deserializer.deserialize({ schema, content: markdown }); - prosemirrorDocument = document; - } catch (e) { - const errorMsg = `Error - check implementation:\n${e.message}`; - return { - html: errorMsg, - json: errorMsg, - }; - } - - const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment( - prosemirrorDocument.content, - ); - const htmlString = documentFragment.firstChild.outerHTML; - - const json = prosemirrorDocument.toJSON(); - const jsonString = JSON.stringify(json, null, 2); - return { html: htmlString, json: jsonString }; -} - -function renderHtmlAndJsonForAllExamples(markdownExamples) { - const { schema } = tiptapEditor; - const deserializer = createMarkdownDeserializer(); - const exampleNames = Object.keys(markdownExamples); - - return exampleNames.reduce(async (promisedExamples, exampleName) => { - const markdown = markdownExamples[exampleName]; - const htmlAndJson = await renderMarkdownToHTMLAndJSON(markdown, schema, deserializer); - const examples = await promisedExamples; - examples[exampleName] = htmlAndJson; - return examples; - }, Promise.resolve({})); -} +import { renderHtmlAndJsonForAllExamples } from 'jest/content_editor/render_html_and_json_for_all_examples'; /* eslint-disable no-undef */ jest.mock('~/emoji'); diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js index 9b83ced10e1bb91c982c773ce960b0d7ee0f214d..5da6676cdc16726e37d8e7c914de28cd4fcfb720 100644 --- a/spec/frontend/__helpers__/matchers/index.js +++ b/spec/frontend/__helpers__/matchers/index.js @@ -2,3 +2,4 @@ export * from './to_have_sprite_icon'; export * from './to_have_tracking_attributes'; export * from './to_match_interpolated_text'; export * from './to_validate_json_schema'; +export * from './to_match_expected_for_markdown'; diff --git a/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js new file mode 100644 index 0000000000000000000000000000000000000000..829f6ba9770824ef2feffd874a8a9eeb1e8520c8 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js @@ -0,0 +1,60 @@ +export function toMatchExpectedForMarkdown( + received, + deserializationTarget, + name, + markdown, + errMsg, + expected, +) { + const options = { + comment: `Markdown deserialization to ${deserializationTarget}`, + isNot: this.isNot, + promise: this.promise, + }; + + const EXPECTED_LABEL = 'Expected'; + const RECEIVED_LABEL = 'Received'; + const isExpand = (expand) => expand !== false; + const forMarkdownName = `for Markdown example '${name}':\n${markdown}`; + const matcherName = `toMatchExpected${ + deserializationTarget === 'HTML' ? 'Html' : 'Json' + }ForMarkdown`; + + let pass; + + // If both expected and received are deserialization errors, force pass = true, + // because the actual error messages can vary across environments and cause + // false failures (e.g. due to jest '--coverage' being passed in CI). + const errMsgRegExp = new RegExp(errMsg); + const errMsgRegExp2 = new RegExp(errMsg); + + if (errMsgRegExp.test(expected) && errMsgRegExp2.test(received)) { + pass = true; + } else { + pass = received === expected; + } + + const message = pass + ? () => + // eslint-disable-next-line prefer-template + this.utils.matcherHint(matcherName, undefined, undefined, options) + + '\n\n' + + `Expected HTML to NOT match:\n${expected}\n\n${forMarkdownName}` + : () => { + return ( + // eslint-disable-next-line prefer-template + this.utils.matcherHint(matcherName, undefined, undefined, options) + + '\n\n' + + this.utils.printDiffOrStringify( + expected, + received, + EXPECTED_LABEL, + RECEIVED_LABEL, + isExpand(this.expand), + ) + + `\n\n${forMarkdownName}` + ); + }; + + return { actual: received, expected, message, name: matcherName, pass }; +} diff --git a/spec/frontend/content_editor/markdown_snapshot_spec.js b/spec/frontend/content_editor/markdown_snapshot_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..aa35a99073e19163d70df7a5215a0fd70a99c03d --- /dev/null +++ b/spec/frontend/content_editor/markdown_snapshot_spec.js @@ -0,0 +1,23 @@ +import path from 'path'; +import { describeMarkdownSnapshots } from 'jest/content_editor/markdown_snapshot_spec_helper'; + +jest.mock('~/emoji'); + +const glfmSpecificationDir = path.join(__dirname, '..', '..', '..', 'glfm_specification'); + +const glfmExampleSnapshotsDir = path.join( + __dirname, + '..', + '..', + 'fixtures', + 'glfm', + 'example_snapshots', +); + +// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing +// for documentation on this spec. +describeMarkdownSnapshots( + 'CE markdown snapshots in ContentEditor', + glfmSpecificationDir, + glfmExampleSnapshotsDir, +); diff --git a/spec/frontend/content_editor/markdown_snapshot_spec_helper.js b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js new file mode 100644 index 0000000000000000000000000000000000000000..05d4a0919216abe5f94429d8384429ea2829c54e --- /dev/null +++ b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js @@ -0,0 +1,105 @@ +// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing +// for documentation on this spec. + +import fs from 'fs'; +import path from 'path'; +import jsYaml from 'js-yaml'; +import { pick } from 'lodash'; +import { + IMPLEMENTATION_ERROR_MSG, + renderHtmlAndJsonForAllExamples, +} from './render_html_and_json_for_all_examples'; + +const filterExamples = (examples) => { + const focusedMarkdownExamples = process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || []; + if (!focusedMarkdownExamples.length) { + return examples; + } + return pick(examples, focusedMarkdownExamples); +}; + +const loadExamples = (dir, fileName) => { + const yaml = fs.readFileSync(path.join(dir, fileName)); + const examples = jsYaml.safeLoad(yaml, {}); + return filterExamples(examples); +}; + +// eslint-disable-next-line jest/no-export +export const describeMarkdownSnapshots = ( + description, + glfmSpecificationDir, + glfmExampleSnapshotsDir, +) => { + let actualHtmlAndJsonExamples; + let skipRunningSnapshotWysiwygHtmlTests; + let skipRunningSnapshotProsemirrorJsonTests; + + const exampleStatuses = loadExamples( + path.join(glfmSpecificationDir, 'input', 'gitlab_flavored_markdown'), + 'glfm_example_status.yml', + ); + const markdownExamples = loadExamples(glfmExampleSnapshotsDir, 'markdown.yml'); + const expectedHtmlExamples = loadExamples(glfmExampleSnapshotsDir, 'html.yml'); + const expectedProseMirrorJsonExamples = loadExamples( + glfmExampleSnapshotsDir, + 'prosemirror_json.yml', + ); + + beforeAll(async () => { + return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => { + actualHtmlAndJsonExamples = examples; + }); + }); + + describe(description, () => { + const exampleNames = Object.keys(markdownExamples); + + describe.each(exampleNames)('%s', (name) => { + const exampleNamePrefix = 'verifies conversion of GLFM to'; + skipRunningSnapshotWysiwygHtmlTests = + exampleStatuses[name]?.skip_running_snapshot_wysiwyg_html_tests; + skipRunningSnapshotProsemirrorJsonTests = + exampleStatuses[name]?.skip_running_snapshot_prosemirror_json_tests; + + const markdown = markdownExamples[name]; + + if (skipRunningSnapshotWysiwygHtmlTests) { + it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`); + } else { + it(`${exampleNamePrefix} HTML`, async () => { + const expectedHtml = expectedHtmlExamples[name].wysiwyg; + const { html: actualHtml } = actualHtmlAndJsonExamples[name]; + + // noinspection JSUnresolvedFunction (required to avoid RubyMine type inspection warning, because custom matchers auto-imported via Jest test setup are not automatically resolved - see https://youtrack.jetbrains.com/issue/WEB-42350/matcher-for-jest-is-not-recognized-but-it-is-runable) + expect(actualHtml).toMatchExpectedForMarkdown( + 'HTML', + name, + markdown, + IMPLEMENTATION_ERROR_MSG, + expectedHtml, + ); + }); + } + + if (skipRunningSnapshotProsemirrorJsonTests) { + it.todo( + `${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`, + ); + } else { + it(`${exampleNamePrefix} ProseMirror JSON`, async () => { + const expectedJson = expectedProseMirrorJsonExamples[name]; + const { json: actualJson } = actualHtmlAndJsonExamples[name]; + + // noinspection JSUnresolvedFunction + expect(actualJson).toMatchExpectedForMarkdown( + 'JSON', + name, + markdown, + IMPLEMENTATION_ERROR_MSG, + expectedJson, + ); + }); + } + }); + }); +}; diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js new file mode 100644 index 0000000000000000000000000000000000000000..8ec1307d9b8f949244449fab52d94c9447b3073b --- /dev/null +++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js @@ -0,0 +1,113 @@ +import { DOMSerializer } from 'prosemirror-model'; +// TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js +// See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan +import Blockquote from '~/content_editor/extensions/blockquote'; +import Bold from '~/content_editor/extensions/bold'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import Code from '~/content_editor/extensions/code'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import DescriptionItem from '~/content_editor/extensions/description_item'; +import DescriptionList from '~/content_editor/extensions/description_list'; +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import Division from '~/content_editor/extensions/division'; +import Emoji from '~/content_editor/extensions/emoji'; +import Figure from '~/content_editor/extensions/figure'; +import FigureCaption from '~/content_editor/extensions/figure_caption'; +import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; +import FootnoteReference from '~/content_editor/extensions/footnote_reference'; +import FootnotesSection from '~/content_editor/extensions/footnotes_section'; +import HardBreak from '~/content_editor/extensions/hard_break'; +import Heading from '~/content_editor/extensions/heading'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import Image from '~/content_editor/extensions/image'; +import InlineDiff from '~/content_editor/extensions/inline_diff'; +import Italic from '~/content_editor/extensions/italic'; +import Link from '~/content_editor/extensions/link'; +import ListItem from '~/content_editor/extensions/list_item'; +import OrderedList from '~/content_editor/extensions/ordered_list'; +import Strike from '~/content_editor/extensions/strike'; +import Table from '~/content_editor/extensions/table'; +import TableCell from '~/content_editor/extensions/table_cell'; +import TableHeader from '~/content_editor/extensions/table_header'; +import TableRow from '~/content_editor/extensions/table_row'; +import TaskItem from '~/content_editor/extensions/task_item'; +import TaskList from '~/content_editor/extensions/task_list'; +import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; +import { createTestEditor } from 'jest/content_editor/test_utils'; + +const tiptapEditor = createTestEditor({ + extensions: [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + DescriptionItem, + DescriptionList, + Details, + DetailsContent, + Division, + Emoji, + FootnoteDefinition, + FootnoteReference, + FootnotesSection, + Figure, + FigureCaption, + HardBreak, + Heading, + HorizontalRule, + Image, + InlineDiff, + Italic, + Link, + ListItem, + OrderedList, + Strike, + Table, + TableCell, + TableHeader, + TableRow, + TaskItem, + TaskList, + ], +}); + +export const IMPLEMENTATION_ERROR_MSG = 'Error - check implementation'; + +async function renderMarkdownToHTMLAndJSON(markdown, schema, deserializer) { + let prosemirrorDocument; + try { + const { document } = await deserializer.deserialize({ schema, content: markdown }); + prosemirrorDocument = document; + } catch (e) { + const errorMsg = `${IMPLEMENTATION_ERROR_MSG}:\n${e.message}`; + return { + html: errorMsg, + json: errorMsg, + }; + } + + const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment( + prosemirrorDocument.content, + ); + const htmlString = documentFragment.firstChild.outerHTML; + + const json = prosemirrorDocument.toJSON(); + const jsonString = JSON.stringify(json, null, 2); + return { html: htmlString, json: jsonString }; +} + +export function renderHtmlAndJsonForAllExamples(markdownExamples) { + const { schema } = tiptapEditor; + const deserializer = createMarkdownDeserializer(); + const exampleNames = Object.keys(markdownExamples); + + return exampleNames.reduce(async (promisedExamples, exampleName) => { + const markdown = markdownExamples[exampleName]; + const htmlAndJson = await renderMarkdownToHTMLAndJSON(markdown, schema, deserializer); + const examples = await promisedExamples; + examples[exampleName] = htmlAndJson; + return examples; + }, Promise.resolve({})); +}