diff options
Diffstat (limited to 'spec/features/markdown')
-rw-r--r-- | spec/features/markdown/copy_as_gfm_spec.rb | 782 | ||||
-rw-r--r-- | spec/features/markdown/gitlab_flavored_markdown_spec.rb | 133 | ||||
-rw-r--r-- | spec/features/markdown/markdown_spec.rb | 337 | ||||
-rw-r--r-- | spec/features/markdown/math_spec.rb | 22 | ||||
-rw-r--r-- | spec/features/markdown/mermaid_spec.rb | 24 |
5 files changed, 1298 insertions, 0 deletions
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb new file mode 100644 index 00000000000..f82ed6300cc --- /dev/null +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -0,0 +1,782 @@ +require 'spec_helper' + +describe 'Copy as GFM', :js do + include MarkupHelper + include RepoHelpers + include ActionView::Helpers::JavaScriptHelper + + before do + sign_in(create(:admin)) + end + + describe 'Copying rendered GFM' do + before do + @feat = MarkdownFeature.new + + # `markdown` helper expects a `@project` variable + @project = @feat.project + + visit project_issue_path(@project, @feat.issue) + end + + # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. + # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM. + # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle + # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. + + # These are all in a single `it` for performance reasons. + it 'works', :aggregate_failures do + verify( + 'nesting', + + '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' + ) + + verify( + 'a real world example from the gitlab-ce README', + + <<-GFM.strip_heredoc + # GitLab + + [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) + [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) + [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) + [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) + + ## Canonical source + + The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). + + ## Open source software to collaborate on code + + To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). + + - Manage Git repositories with fine grained access controls that keep your code secure + + - Perform code reviews and enhance collaboration with merge requests + + - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications + + - Each project can also have an issue tracker, issue board, and a wiki + + - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises + + - Completely free and open source (MIT Expat license) + GFM + ) + + aggregate_failures('an accidentally selected empty element') do + gfm = '# Heading1' + + html = <<-HTML.strip_heredoc + <h1>Heading1</h1> + + <h2></h2> + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + aggregate_failures('an accidentally selected other element') do + gfm = 'Test comment with **Markdown!**' + + html = <<-HTML.strip_heredoc + <li class="note"> + <div class="md"> + <p> + Test comment with <strong>Markdown!</strong> + </p> + </div> + </li> + + <li class="note"></li> + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + verify( + 'InlineDiffFilter', + + '{-Deleted text-}', + '{+Added text+}' + ) + + verify( + 'TaskListFilter', + + '- [ ] Unchecked task', + '- [x] Checked task', + '1. [ ] Unchecked numbered task', + '1. [x] Checked numbered task' + ) + + verify( + 'ReferenceFilter', + + # issue reference + @feat.issue.to_reference, + # full issue reference + @feat.issue.to_reference(full: true), + # issue URL + project_issue_url(@project, @feat.issue), + # issue URL with note anchor + project_issue_url(@project, @feat.issue, anchor: 'note_123'), + # issue link + "[Issue](#{project_issue_url(@project, @feat.issue)})", + # issue link with note anchor + "[Issue](#{project_issue_url(@project, @feat.issue, anchor: 'note_123')})" + ) + + verify( + 'AutolinkFilter', + + 'https://example.com' + ) + + verify( + 'TableOfContentsFilter', + + '[[_TOC_]]' + ) + + verify( + 'EmojiFilter', + + ':thumbsup:' + ) + + verify( + 'ImageLinkFilter', + + '![Image](https://example.com/image.png)' + ) + + verify( + 'VideoLinkFilter', + + '![Video](https://example.com/video.mp4)' + ) + + verify( + 'MathFilter: math as converted from GFM to HTML', + + '$`c = \pm\sqrt{a^2 + b^2}`$', + + # math block + <<-GFM.strip_heredoc + ```math + c = \pm\sqrt{a^2 + b^2} + ``` + GFM + ) + + aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do + gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' + + html = <<-HTML.strip_heredoc + <span class="katex"> + <span class="katex-mathml"> + <math> + <semantics> + <mrow> + <mi>c</mi> + <mo>=</mo> + <mo>±</mo> + <msqrt> + <mrow> + <msup> + <mi>a</mi> + <mn>2</mn> + </msup> + <mo>+</mo> + <msup> + <mi>b</mi> + <mn>2</mn> + </msup> + </mrow> + </msqrt> + </mrow> + <annotation encoding="application/x-tex">c = \\pm\\sqrt{a^2 + b^2}</annotation> + </semantics> + </math> + </span> + <span class="katex-html" aria-hidden="true"> + <span class="strut" style="height: 0.913389em;"></span> + <span class="strut bottom" style="height: 1.04em; vertical-align: -0.126611em;"></span> + <span class="base textstyle uncramped"> + <span class="mord mathit">c</span> + <span class="mrel">=</span> + <span class="mord">±</span> + <span class="sqrt mord"><span class="sqrt-sign" style="top: -0.073389em;"> + <span class="style-wrap reset-textstyle textstyle uncramped">√</span> + </span> + <span class="vlist"> + <span class="" style="top: 0em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 1em;"></span> + </span> + <span class="mord textstyle cramped"> + <span class="mord"> + <span class="mord mathit">a</span> + <span class="msupsub"> + <span class="vlist"> + <span class="" style="top: -0.289em; margin-right: 0.05em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + <span class="reset-textstyle scriptstyle cramped"> + <span class="mord mathrm">2</span> + </span> + </span> + <span class="baseline-fix"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + </span> + </span> + </span> + </span> + <span class="mbin">+</span> + <span class="mord"> + <span class="mord mathit">b</span> + <span class="msupsub"> + <span class="vlist"> + <span class="" style="top: -0.289em; margin-right: 0.05em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + <span class="reset-textstyle scriptstyle cramped"> + <span class="mord mathrm">2</span> + </span> + </span> + <span class="baseline-fix"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + </span> + </span> + </span> + </span> + </span> + </span> + <span class="" style="top: -0.833389em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 1em;"></span> + </span> + <span class="reset-textstyle textstyle uncramped sqrt-line"></span> + </span> + <span class="baseline-fix"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 1em;"></span> + </span> + </span> + </span> + </span> + </span> + </span> + </span> + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + verify( + 'MermaidFilter: mermaid as converted from GFM to HTML', + + <<-GFM.strip_heredoc + ```mermaid + graph TD; + A-->B; + ``` + GFM + ) + + aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do + gfm = <<-GFM.strip_heredoc + ```mermaid + graph TD; + A-->B; + ``` + GFM + + html = <<-HTML.strip_heredoc + <svg id="mermaidChart1" xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="0 0 87.234375 174" style="max-width:87.234375px;" class="mermaid"> + <style> + .mermaid { + /* Flowchart variables */ + /* Sequence Diagram variables */ + /* Gantt chart variables */ + /** Section styling */ + /* Grid and axis */ + /* Today line */ + /* Task styling */ + /* Default task */ + /* Specific task settings for the sections*/ + /* Active task */ + /* Completed task */ + /* Tasks on the critical line */ + } + </style> + <g> + <g class="output"> + <g class="clusters"></g> + <g class="edgePaths"> + <g class="edgePath" style="opacity: 1;"> + <path class="path" d="M33.6171875,52L33.6171875,77L33.6171875,102" marker-end="url(#arrowhead65)" style="fill:none"></path> + <defs> + <marker id="arrowhead65" viewBox="0 0 10 10" refX="9" refY="5" markerUnits="strokeWidth" markerWidth="8" markerHeight="6" orient="auto"> + <path d="M 0 0 L 10 5 L 0 10 z" class="arrowheadPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path> + </marker> + </defs> + </g> + </g> + <g class="edgeLabels"> + <g class="edgeLabel" style="opacity: 1;" transform=""> + <g transform="translate(0,0)" class="label"> + <foreignObject width="0" height="0"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;"> + <span class="edgeLabel"></span> + </div> + </foreignObject> + </g> + </g> + </g> + <g class="nodes"> + <g class="node" id="A" transform="translate(33.6171875,36)" style="opacity: 1;"> + <rect rx="0" ry="0" x="-13.6171875" y="-16" width="27.234375" height="32"></rect> + <g class="label" transform="translate(0,0)"> + <g transform="translate(-3.6171875,-6)"> + <foreignObject width="7.234375" height="12"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;">A</div> + </foreignObject> + </g> + </g> + </g> + <g class="node" id="B" transform="translate(33.6171875,118)" style="opacity: 1;"> + <rect rx="0" ry="0" x="-13.6171875" y="-16" width="27.234375" height="32"> + </rect> + <g class="label" transform="translate(0,0)"> + <g transform="translate(-3.6171875,-6)"> + <foreignObject width="7.234375" height="12"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;">B</div> + </foreignObject> + </g> + </g> + </g> + </g> + </g> + </g> + <text class="source" display="none">graph TD; + A-->B; + </text> + </svg> + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + verify( + 'SanitizationFilter', + + <<-GFM.strip_heredoc + <sub>sub</sub> + + <dl> + <dt>dt</dt> + <dd>dd</dd> + </dl> + + <kbd>kbd</kbd> + + <q>q</q> + + <samp>samp</samp> + + <var>var</var> + + <ruby>ruby</ruby> + + <rt>rt</rt> + + <rp>rp</rp> + + <abbr>abbr</abbr> + + <summary>summary</summary> + + <details>details</details> + GFM + ) + + verify( + 'SanitizationFilter', + + <<-GFM.strip_heredoc, + ``` + Plain text + ``` + GFM + + <<-GFM.strip_heredoc, + ```ruby + def foo + bar + end + ``` + GFM + + <<-GFM.strip_heredoc + Foo + + This is an example of GFM + + ```js + Code goes here + ``` + GFM + ) + + verify( + 'MarkdownFilter', + + "Line with two spaces at the end \nto insert a linebreak", + + '`code`', + '`` code with ` ticks ``', + + '> Quote', + + # multiline quote + <<-GFM.strip_heredoc, + > Multiline + > Quote + > + > With multiple paragraphs + GFM + + '![Image](https://example.com/image.png)', + + '# Heading with no anchor link', + + '[Link](https://example.com)', + + '- List item', + + # multiline list item + <<-GFM.strip_heredoc, + - Multiline + List item + GFM + + # nested lists + <<-GFM.strip_heredoc, + - Nested + + - Lists + GFM + + # list with blockquote + <<-GFM.strip_heredoc, + - List + + > Blockquote + GFM + + '1. Numbered list item', + + # multiline numbered list item + <<-GFM.strip_heredoc, + 1. Multiline + Numbered list item + GFM + + # nested numbered list + <<-GFM.strip_heredoc, + 1. Nested + + 1. Numbered lists + GFM + + '# Heading', + '## Heading', + '### Heading', + '#### Heading', + '##### Heading', + '###### Heading', + + '**Bold**', + + '_Italics_', + + '~~Strikethrough~~', + + '2^2', + + '-----', + + # table + <<-GFM.strip_heredoc, + | Centered | Right | Left | + |:--------:|------:|------| + | Foo | Bar | **Baz** | + | Foo | Bar | **Baz** | + GFM + + # table with empty heading + <<-GFM.strip_heredoc, + | | x | y | + |---|---|---| + | a | 1 | 0 | + | b | 0 | 1 | + GFM + ) + end + + alias_method :gfm_to_html, :markdown + + def verify(label, *gfms) + aggregate_failures(label) do + gfms.each do |gfm| + html = gfm_to_html(gfm).gsub(/\A
|
\z/, '') + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + end + end + + # Fake a `current_user` helper + def current_user + @feat.user + end + end + + describe 'Copying code' do + let(:project) { create(:project, :repository) } + + context 'from a diff' do + shared_examples 'copying code from a diff' do + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no', + + '`RuntimeError`', + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' + ) + end + end + + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]', + + '`raise RuntimeError, "System commands must be given as an array of strings"`', + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' + ) + end + end + + context 'selecting multiple lines of text' do + it 'copies as a code block' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', + + <<-GFM.strip_heredoc, + ```ruby + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' + ) + end + end + end + + context 'inline diff' do + before do + visit project_commit_path(project, sample_commit.id, view: 'inline') + end + + it_behaves_like 'copying code from a diff' + end + + context 'parallel diff' do + before do + visit project_commit_path(project, sample_commit.id, view: 'parallel') + end + + it_behaves_like 'copying code from a diff' + + context 'selecting code on the left' do + it 'copies as a code block' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', + + <<-GFM.strip_heredoc, + ```ruby + unless cmd.is_a?(Array) + raise "System commands must be given as an array of strings" + end + ``` + GFM + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].left-side' + ) + end + end + + context 'selecting code on the right' do + it 'copies as a code block' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', + + <<-GFM.strip_heredoc, + ```ruby + unless cmd.is_a?(Array) + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].right-side' + ) + end + end + end + end + + context 'from a blob' do + before do + visit project_blob_path(project, File.join('master', 'files/ruby/popen.rb')) + wait_for_requests + end + + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '.line[id="LC9"] .no', + + '`RuntimeError`' + ) + end + end + + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '.line[id="LC9"]', + + '`raise RuntimeError, "System commands must be given as an array of strings"`' + ) + end + end + + context 'selecting multiple lines of text' do + it 'copies as a code block' do + verify( + '.line[id="LC9"], .line[id="LC10"]', + + <<-GFM.strip_heredoc, + ```ruby + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + ) + end + end + end + + context 'from a GFM code block' do + before do + visit project_blob_path(project, File.join('markdown', 'doc/api/users.md')) + wait_for_requests + end + + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '.line[id="LC27"] .s2', + + '`"bio"`' + ) + end + end + + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '.line[id="LC27"]', + + '`"bio": null,`' + ) + end + end + + context 'selecting multiple lines of text' do + it 'copies as a code block with the correct language' do + verify( + '.line[id="LC27"], .line[id="LC28"]', + + <<-GFM.strip_heredoc, + ```json + "bio": null, + "skype": "", + ``` + GFM + ) + end + end + end + + def verify(selector, gfm, target: nil) + html = html_for_selector(selector) + output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target) + expect(output_gfm.strip).to eq(gfm.strip) + end + end + + def html_for_selector(selector) + js = <<-JS.strip_heredoc + (function(selector) { + var els = document.querySelectorAll(selector); + var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; }); + return htmls.join("\\n"); + })("#{escape_javascript(selector)}") + JS + page.evaluate_script(js) + end + + def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil) + js = <<-JS.strip_heredoc + (function(html) { + var transformer = window.CopyAsGFM[#{transformer.inspect}]; + + var node = document.createElement('div'); + $(html).each(function() { node.appendChild(this) }); + + var targetSelector = #{target.to_json}; + var target; + if (targetSelector) { + target = document.querySelector(targetSelector); + } + + node = transformer(node, target); + if (!node) return null; + + return window.CopyAsGFM.nodeToGFM(node); + })("#{escape_javascript(html)}") + JS + page.evaluate_script(js) + end +end diff --git a/spec/features/markdown/gitlab_flavored_markdown_spec.rb b/spec/features/markdown/gitlab_flavored_markdown_spec.rb new file mode 100644 index 00000000000..3c2186b3598 --- /dev/null +++ b/spec/features/markdown/gitlab_flavored_markdown_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +describe "GitLab Flavored Markdown" do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project) } + let(:fred) do + create(:user, name: 'fred') do |user| + project.add_master(user) + end + end + + before do + sign_in(user) + project.add_developer(user) + end + + describe "for commits" do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit } + + before do + allow_any_instance_of(Commit).to receive(:title) + .and_return("fix #{issue.to_reference}\n\nask #{fred.to_reference} for details") + end + + it "renders title in commits#index" do + visit project_commits_path(project, 'master', limit: 1) + + expect(page).to have_link(issue.to_reference) + end + + it "renders title in commits#show" do + visit project_commit_path(project, commit) + + expect(page).to have_link(issue.to_reference) + end + + it "renders description in commits#show" do + visit project_commit_path(project, commit) + + expect(page).to have_link(fred.to_reference) + end + + it "renders title in repositories#branches" do + visit project_branches_path(project) + + expect(page).to have_link(issue.to_reference) + end + end + + describe "for issues", :js do + before do + @other_issue = create(:issue, + author: user, + assignees: [user], + project: project) + @issue = create(:issue, + author: user, + assignees: [user], + project: project, + title: "fix #{@other_issue.to_reference}", + description: "ask #{fred.to_reference} for details") + + @note = create(:note_on_issue, noteable: @issue, project: @issue.project, note: "Hello world") + end + + it "renders subject in issues#index" do + visit project_issues_path(project) + + expect(page).to have_link(@other_issue.to_reference) + end + + it "renders subject in issues#show" do + visit project_issue_path(project, @issue) + + expect(page).to have_link(@other_issue.to_reference) + end + + it "renders details in issues#show" do + visit project_issue_path(project, @issue) + + expect(page).to have_link(fred.to_reference) + end + end + + describe "for merge requests" do + let(:project) { create(:project, :repository) } + + before do + @merge_request = create(:merge_request, source_project: project, target_project: project, title: "fix #{issue.to_reference}") + end + + it "renders title in merge_requests#index" do + visit project_merge_requests_path(project) + + expect(page).to have_link(issue.to_reference) + end + + it "renders title in merge_requests#show" do + visit project_merge_request_path(project, @merge_request) + + expect(page).to have_link(issue.to_reference) + end + end + + describe "for milestones" do + before do + @milestone = create(:milestone, + project: project, + title: "fix #{issue.to_reference}", + description: "ask #{fred.to_reference} for details") + end + + it "renders title in milestones#index" do + visit project_milestones_path(project) + + expect(page).to have_link(issue.to_reference) + end + + it "renders title in milestones#show" do + visit project_milestone_path(project, @milestone) + + expect(page).to have_link(issue.to_reference) + end + + it "renders description in milestones#show" do + visit project_milestone_path(project, @milestone) + + expect(page).to have_link(fred.to_reference) + end + end +end diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb new file mode 100644 index 00000000000..f13d78d24e3 --- /dev/null +++ b/spec/features/markdown/markdown_spec.rb @@ -0,0 +1,337 @@ +require 'spec_helper' +require 'erb' + +# This feature spec is intended to be a comprehensive exercising of all of +# GitLab's non-standard Markdown parsing and the integration thereof. +# +# These tests should be very high-level. Anything low-level belongs in the specs +# for the corresponding HTML::Pipeline filter or helper method. +# +# The idea is to pass a Markdown document through our entire processing stack. +# +# The process looks like this: +# +# Raw Markdown +# -> `markdown` helper +# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML +# -> Post-process HTML +# -> `gfm` helper +# -> HTML::Pipeline +# -> SanitizationFilter +# -> Other filters, depending on pipeline +# -> `html_safe` +# -> Template +# +# See the MarkdownFeature class for setup details. + +describe 'GitLab Markdown' do + include Capybara::Node::Matchers + include MarkupHelper + include MarkdownMatchers + + # Sometimes it can be useful to see the parsed output of the Markdown document + # for debugging. Call this method to write the output to + # `tmp/capybara/<filename>.html`. + def write_markdown(filename = 'markdown_spec') + File.open(Rails.root.join("tmp/capybara/#{filename}.html"), 'w') do |file| + file.puts @html + end + end + + def doc(html = @html) + @doc ||= Nokogiri::HTML::DocumentFragment.parse(html) + end + + # Shared behavior that all pipelines should exhibit + shared_examples 'all pipelines' do + describe 'Redcarpet extensions' do + it 'does not parse emphasis inside of words' do + expect(doc.to_html).not_to match('foo<em>bar</em>baz') + end + + it 'parses table Markdown' do + aggregate_failures do + expect(doc).to have_selector('th:contains("Header")') + expect(doc).to have_selector('th:contains("Row")') + expect(doc).to have_selector('th:contains("Example")') + end + end + + it 'allows Markdown in tables' do + expect(doc.at_css('td:contains("Baz")').children.to_html) + .to eq '<strong>Baz</strong>' + end + + it 'parses fenced code blocks' do + aggregate_failures do + expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.c') + expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.python') + end + end + + it 'parses mermaid code block' do + aggregate_failures do + expect(doc).to have_selector('pre[lang=mermaid] > code.js-render-mermaid') + end + end + + it 'parses strikethroughs' do + expect(doc).to have_selector(%{del:contains("and this text doesn't")}) + end + + it 'parses superscript' do + expect(doc).to have_selector('sup', count: 2) + end + end + + describe 'SanitizationFilter' do + it 'permits b elements' do + expect(doc).to have_selector('b:contains("b tag")') + end + + it 'permits em elements' do + expect(doc).to have_selector('em:contains("em tag")') + end + + it 'permits code elements' do + expect(doc).to have_selector('code:contains("code tag")') + end + + it 'permits kbd elements' do + expect(doc).to have_selector('kbd:contains("s")') + end + + it 'permits strike elements' do + expect(doc).to have_selector('strike:contains(Emoji)') + end + + it 'permits img elements' do + expect(doc).to have_selector('img[data-src*="smile.png"]') + end + + it 'permits br elements' do + expect(doc).to have_selector('br') + end + + it 'permits hr elements' do + expect(doc).to have_selector('hr') + end + + it 'permits span elements' do + expect(doc).to have_selector('span:contains("span tag")') + end + + it 'permits details elements' do + expect(doc).to have_selector('details:contains("Hiding the details")') + end + + it 'permits summary elements' do + expect(doc).to have_selector('details summary:contains("collapsible")') + end + + it 'permits style attribute in th elements' do + aggregate_failures do + expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center' + expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right' + expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left' + end + end + + it 'permits style attribute in td elements' do + aggregate_failures do + expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center' + expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right' + expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left' + end + end + + it 'removes `rel` attribute from links' do + expect(doc).not_to have_selector('a[rel="bookmark"]') + end + + it "removes `href` from `a` elements if it's fishy" do + expect(doc).not_to have_selector('a[href*="javascript"]') + end + end + + describe 'Escaping' do + it 'escapes non-tag angle brackets' do + table = doc.css('table').last.at_css('tbody') + expect(table.at_xpath('.//tr[1]/td[3]').inner_html).to eq '1 < 3 & 5' + end + end + + describe 'Edge Cases' do + it 'allows markup inside link elements' do + aggregate_failures do + expect(doc.at_css('a[href="#link-emphasis"]').to_html) + .to eq %{<a href="#link-emphasis"><em>text</em></a>} + + expect(doc.at_css('a[href="#link-strong"]').to_html) + .to eq %{<a href="#link-strong"><strong>text</strong></a>} + + expect(doc.at_css('a[href="#link-code"]').to_html) + .to eq %{<a href="#link-code"><code>text</code></a>} + end + end + end + + describe 'ExternalLinkFilter' do + it 'adds nofollow to external link' do + link = doc.at_css('a:contains("Google")') + + expect(link.attr('rel')).to include('nofollow') + end + + it 'adds noreferrer to external link' do + link = doc.at_css('a:contains("Google")') + + expect(link.attr('rel')).to include('noreferrer') + end + + it 'adds _blank to target attribute for external links' do + link = doc.at_css('a:contains("Google")') + + expect(link.attr('target')).to match('_blank') + end + + it 'ignores internal link' do + link = doc.at_css('a:contains("GitLab Root")') + + expect(link.attr('rel')).not_to match 'nofollow' + expect(link.attr('target')).not_to match '_blank' + end + end + end + + before do + @feat = MarkdownFeature.new + + # `markdown` helper expects a `@project` and `@group` variable + @project = @feat.project + @group = @feat.group + end + + context 'default pipeline' do + before do + @html = markdown(@feat.raw_markdown) + end + + it_behaves_like 'all pipelines' + + it 'includes RelativeLinkFilter' do + expect(doc).to parse_relative_links + end + + it 'includes EmojiFilter' do + expect(doc).to parse_emoji + end + + it 'includes TableOfContentsFilter' do + expect(doc).to create_header_links + end + + it 'includes AutolinkFilter' do + expect(doc).to create_autolinks + end + + it 'includes all reference filters' do + aggregate_failures do + expect(doc).to reference_users + expect(doc).to reference_issues + expect(doc).to reference_merge_requests + expect(doc).to reference_snippets + expect(doc).to reference_commit_ranges + expect(doc).to reference_commits + expect(doc).to reference_labels + expect(doc).to reference_milestones + end + end + + it 'includes TaskListFilter' do + expect(doc).to parse_task_lists + end + + it 'includes InlineDiffFilter' do + expect(doc).to parse_inline_diffs + end + + it 'includes VideoLinkFilter' do + expect(doc).to parse_video_links + end + + it 'includes ColorFilter' do + expect(doc).to parse_colors + end + end + + context 'wiki pipeline' do + before do + @project_wiki = @feat.project_wiki + @project_wiki_page = @feat.project_wiki_page + + file = Gollum::File.new(@project_wiki.wiki) + expect(file).to receive(:path).and_return('images/example.jpg') + expect(@project_wiki).to receive(:find_file).with('images/example.jpg').and_return(file) + allow(@project_wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' } + + @html = markdown(@feat.raw_markdown, { pipeline: :wiki, project_wiki: @project_wiki, page_slug: @project_wiki_page.slug }) + end + + it_behaves_like 'all pipelines' + + it 'includes RelativeLinkFilter' do + expect(doc).not_to parse_relative_links + end + + it 'includes EmojiFilter' do + expect(doc).to parse_emoji + end + + it 'includes TableOfContentsFilter' do + expect(doc).to create_header_links + end + + it 'includes AutolinkFilter' do + expect(doc).to create_autolinks + end + + it 'includes all reference filters' do + aggregate_failures do + expect(doc).to reference_users + expect(doc).to reference_issues + expect(doc).to reference_merge_requests + expect(doc).to reference_snippets + expect(doc).to reference_commit_ranges + expect(doc).to reference_commits + expect(doc).to reference_labels + expect(doc).to reference_milestones + end + end + + it 'includes TaskListFilter' do + expect(doc).to parse_task_lists + end + + it 'includes GollumTagsFilter' do + expect(doc).to parse_gollum_tags + end + + it 'includes InlineDiffFilter' do + expect(doc).to parse_inline_diffs + end + + it 'includes VideoLinkFilter' do + expect(doc).to parse_video_links + end + + it 'includes ColorFilter' do + expect(doc).to parse_colors + end + end + + # Fake a `current_user` helper + def current_user + @feat.user + end +end diff --git a/spec/features/markdown/math_spec.rb b/spec/features/markdown/math_spec.rb new file mode 100644 index 00000000000..6a23d6b78ab --- /dev/null +++ b/spec/features/markdown/math_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe 'Math rendering', :js do + it 'renders inline and display math correctly' do + description = <<~MATH + This math is inline $`a^2+b^2=c^2`$. + + This is on a separate line + ```math + a^2+b^2=c^2 + ``` + MATH + + project = create(:project, :public) + issue = create(:issue, project: project, description: description) + + visit project_issue_path(project, issue) + + expect(page).to have_selector('.katex .mord.mathit', text: 'b') + expect(page).to have_selector('.katex-display .mord.mathit', text: 'b') + end +end diff --git a/spec/features/markdown/mermaid_spec.rb b/spec/features/markdown/mermaid_spec.rb new file mode 100644 index 00000000000..a25d701ee35 --- /dev/null +++ b/spec/features/markdown/mermaid_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe 'Mermaid rendering', :js do + it 'renders Mermaid diagrams correctly' do + description = <<~MERMAID + ```mermaid + graph TD; + A-->B; + A-->C; + B-->D; + C-->D; + ``` + MERMAID + + project = create(:project, :public) + issue = create(:issue, project: project, description: description) + + visit project_issue_path(project, issue) + + %w[A B C D].each do |label| + expect(page).to have_selector('svg foreignObject', text: label) + end + end +end |