diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-28 15:11:31 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-28 15:11:31 +0300 |
commit | 2ebd699ede8f213f6e8f21ba7d1d9904197b2984 (patch) | |
tree | ea8a020f8bc1ffce42e95f76629c72c59e94a7be /spec/frontend | |
parent | 25788905108838d95a62d7e3ad3ca16e6f6d0fda (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
-rw-r--r-- | spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js | 145 | ||||
-rw-r--r-- | spec/frontend/issues/list/components/issues_list_app_spec.js | 56 | ||||
-rw-r--r-- | spec/frontend/notebook/cells/markdown_spec.js | 101 | ||||
-rw-r--r-- | spec/frontend/notebook/cells/output/index_spec.js | 14 | ||||
-rw-r--r-- | spec/frontend/notebook/cells/output/markdown_spec.js | 44 | ||||
-rw-r--r-- | spec/frontend/notebook/mock_data.js | 2 | ||||
-rw-r--r-- | spec/frontend/releases/components/asset_links_form_spec.js | 42 |
7 files changed, 292 insertions, 112 deletions
diff --git a/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js index 2b9442162aa..de0e5063e49 100644 --- a/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js +++ b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js @@ -1,34 +1,127 @@ -import $ from 'jquery'; +import { createWrapper } from '@vue/test-utils'; +import { __ } from '~/locale'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import renderMermaid from '~/behaviors/markdown/render_sandboxed_mermaid'; +import renderMermaid, { + MAX_CHAR_LIMIT, + MAX_MERMAID_BLOCK_LIMIT, + LAZY_ALERT_SHOWN_CLASS, +} from '~/behaviors/markdown/render_sandboxed_mermaid'; -describe('Render mermaid diagrams for Gitlab Flavoured Markdown', () => { - it('Does something', () => { - document.body.dataset.page = ''; - setHTMLFixture(` - <div class="gl-relative markdown-code-block js-markdown-code"> - <pre data-sourcepos="1:1-7:3" class="code highlight js-syntax-highlight language-mermaid white" lang="mermaid" id="code-4"> - <code class="js-render-mermaid"> - <span id="LC1" class="line" lang="mermaid">graph TD;</span> - <span id="LC2" class="line" lang="mermaid">A-->B</span> - <span id="LC3" class="line" lang="mermaid">A-->C</span> - <span id="LC4" class="line" lang="mermaid">B-->D</span> - <span id="LC5" class="line" lang="mermaid">C-->D</span> - </code> - </pre> - <copy-code> - <button type="button" class="btn btn-default btn-md gl-button btn-icon has-tooltip" data-title="Copy to clipboard" data-clipboard-target="pre#code-4"> - <svg><use xlink:href="/assets/icons-7f1680a3670112fe4c8ef57b9dfb93f0f61b43a2a479d7abd6c83bcb724b9201.svg#copy-to-clipboard"></use></svg> - </button> - </copy-code> - </div>`); - const els = $('pre.js-syntax-highlight').find('.js-render-mermaid'); - - renderMermaid(els); +describe('Mermaid diagrams renderer', () => { + // Finders + const findMermaidIframes = () => document.querySelectorAll('iframe[src="/-/sandbox/mermaid"]'); + const findDangerousMermaidAlert = () => + createWrapper(document.querySelector('[data-testid="alert-warning"]')); + // Helpers + const renderDiagrams = () => { + renderMermaid([...document.querySelectorAll('.js-render-mermaid')]); jest.runAllTimers(); - expect(document.querySelector('pre.js-syntax-highlight').classList).toContain('gl-sr-only'); + }; + + beforeEach(() => { + document.body.dataset.page = ''; + }); + afterEach(() => { resetHTMLFixture(); }); + + it('renders a mermaid diagram', () => { + setHTMLFixture('<pre><code class="js-render-mermaid"></code></pre>'); + + expect(findMermaidIframes()).toHaveLength(0); + + renderDiagrams(); + + expect(document.querySelector('pre').classList).toContain('gl-sr-only'); + expect(findMermaidIframes()).toHaveLength(1); + }); + + describe('within a details element', () => { + beforeEach(() => { + setHTMLFixture('<details><pre><code class="js-render-mermaid"></code></pre></details>'); + renderDiagrams(); + }); + + it('does not render the diagram on load', () => { + expect(findMermaidIframes()).toHaveLength(0); + }); + + it('render the diagram when the details element is opened', () => { + document.querySelector('details').setAttribute('open', true); + document.querySelector('details').dispatchEvent(new Event('toggle')); + jest.runAllTimers(); + + expect(findMermaidIframes()).toHaveLength(1); + }); + }); + + describe('dangerous diagrams', () => { + describe(`when the diagram's source exceeds ${MAX_CHAR_LIMIT} characters`, () => { + beforeEach(() => { + setHTMLFixture( + `<pre> + <code class="js-render-mermaid">${Array(MAX_CHAR_LIMIT + 1) + .fill('a') + .join('')}</code> + </pre>`, + ); + renderDiagrams(); + }); + it('does not render the diagram on load', () => { + expect(findMermaidIframes()).toHaveLength(0); + }); + + it('shows a warning about performance impact when rendering the diagram', () => { + expect(document.querySelector('pre').classList).toContain(LAZY_ALERT_SHOWN_CLASS); + expect(findDangerousMermaidAlert().exists()).toBe(true); + expect(findDangerousMermaidAlert().text()).toContain( + __('Warning: Displaying this diagram might cause performance issues on this page.'), + ); + }); + + it("renders the diagram when clicking on the alert's button", () => { + findDangerousMermaidAlert().find('button').trigger('click'); + jest.runAllTimers(); + + expect(findMermaidIframes()).toHaveLength(1); + }); + }); + + it(`stops rendering diagrams once the total rendered source exceeds ${MAX_CHAR_LIMIT} characters`, () => { + setHTMLFixture( + `<pre> + <code class="js-render-mermaid">${Array(MAX_CHAR_LIMIT - 1) + .fill('a') + .join('')}</code> + <code class="js-render-mermaid">2</code> + <code class="js-render-mermaid">3</code> + <code class="js-render-mermaid">4</code> + </pre>`, + ); + renderDiagrams(); + + expect(findMermaidIframes()).toHaveLength(3); + }); + + // Note: The test case below is provided for convenience but should remain skipped as the DOM + // operations it requires are too expensive and would significantly slow down the test suite. + // eslint-disable-next-line jest/no-disabled-tests + it.skip(`stops rendering diagrams when the rendered diagrams count exceeds ${MAX_MERMAID_BLOCK_LIMIT}`, () => { + setHTMLFixture( + `<pre> + ${Array(MAX_MERMAID_BLOCK_LIMIT + 1) + .fill('<code class="js-render-mermaid"></code>') + .join('')} + </pre>`, + ); + renderDiagrams(); + + expect([...document.querySelectorAll('.js-render-mermaid')]).toHaveLength( + MAX_MERMAID_BLOCK_LIMIT + 1, + ); + expect(findMermaidIframes()).toHaveLength(MAX_MERMAID_BLOCK_LIMIT); + }); + }); }); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index 5133c02b190..16631752d6d 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -131,7 +131,6 @@ describe('CE IssuesListApp component', () => { const mountComponent = ({ provide = {}, data = {}, - workItems = false, issuesQueryResponse = mockIssuesQueryResponse, issuesCountsQueryResponse = mockIssuesCountsQueryResponse, sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), @@ -150,9 +149,6 @@ describe('CE IssuesListApp component', () => { apolloProvider: createMockApollo(requestHandlers), router, provide: { - glFeatures: { - workItems, - }, ...defaultProvide, ...provide, }, @@ -1060,45 +1056,23 @@ describe('CE IssuesListApp component', () => { }); describe('fetching issues', () => { - describe('when work_items feature flag is disabled', () => { - beforeEach(() => { - wrapper = mountComponent({ workItems: false }); - jest.runOnlyPendingTimers(); - }); - - it('fetches issue, incident, and test case types', () => { - const types = [ - WORK_ITEM_TYPE_ENUM_ISSUE, - WORK_ITEM_TYPE_ENUM_INCIDENT, - WORK_ITEM_TYPE_ENUM_TEST_CASE, - ]; - - expect(mockIssuesQueryResponse).toHaveBeenCalledWith(expect.objectContaining({ types })); - expect(mockIssuesCountsQueryResponse).toHaveBeenCalledWith( - expect.objectContaining({ types }), - ); - }); + beforeEach(() => { + wrapper = mountComponent(); + jest.runOnlyPendingTimers(); }); - describe('when work_items feature flag is enabled', () => { - beforeEach(() => { - wrapper = mountComponent({ workItems: true }); - jest.runOnlyPendingTimers(); - }); - - it('fetches issue, incident, test case, and task types', () => { - const types = [ - WORK_ITEM_TYPE_ENUM_ISSUE, - WORK_ITEM_TYPE_ENUM_INCIDENT, - WORK_ITEM_TYPE_ENUM_TEST_CASE, - WORK_ITEM_TYPE_ENUM_TASK, - ]; - - expect(mockIssuesQueryResponse).toHaveBeenCalledWith(expect.objectContaining({ types })); - expect(mockIssuesCountsQueryResponse).toHaveBeenCalledWith( - expect.objectContaining({ types }), - ); - }); + it('fetches issue, incident, test case, and task types', () => { + const types = [ + WORK_ITEM_TYPE_ENUM_ISSUE, + WORK_ITEM_TYPE_ENUM_INCIDENT, + WORK_ITEM_TYPE_ENUM_TEST_CASE, + WORK_ITEM_TYPE_ENUM_TASK, + ]; + + expect(mockIssuesQueryResponse).toHaveBeenCalledWith(expect.objectContaining({ types })); + expect(mockIssuesCountsQueryResponse).toHaveBeenCalledWith( + expect.objectContaining({ types }), + ); }); }); }); diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index c757b55faf4..a7776bd5b69 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -5,20 +5,22 @@ import markdownTableJson from 'test_fixtures/blob/notebook/markdown-table.json'; import basicJson from 'test_fixtures/blob/notebook/basic.json'; import mathJson from 'test_fixtures/blob/notebook/math.json'; import MarkdownComponent from '~/notebook/cells/markdown.vue'; +import Prompt from '~/notebook/cells/prompt.vue'; const Component = Vue.extend(MarkdownComponent); window.katex = katex; -function buildCellComponent(cell, relativePath = '') { +function buildCellComponent(cell, relativePath = '', hidePrompt) { return mount(Component, { propsData: { cell, + hidePrompt, }, provide: { relativeRawPath: relativePath, }, - }).vm; + }); } function buildMarkdownComponent(markdownContent, relativePath = '') { @@ -33,7 +35,7 @@ function buildMarkdownComponent(markdownContent, relativePath = '') { } describe('Markdown component', () => { - let vm; + let wrapper; let cell; let json; @@ -43,21 +45,30 @@ describe('Markdown component', () => { // eslint-disable-next-line prefer-destructuring cell = json.cells[1]; - vm = buildCellComponent(cell); + wrapper = buildCellComponent(cell); await nextTick(); }); - it('does not render prompt', () => { - expect(vm.$el.querySelector('.prompt span')).toBeNull(); + const findPrompt = () => wrapper.findComponent(Prompt); + + it('renders a prompt by default', () => { + expect(findPrompt().exists()).toBe(true); + }); + + it('does not render a prompt if hidePrompt is true', () => { + wrapper = buildCellComponent(cell, '', true); + expect(findPrompt().exists()).toBe(false); }); it('does not render the markdown text', () => { - expect(vm.$el.querySelector('.markdown').innerHTML.trim()).not.toEqual(cell.source.join('')); + expect(wrapper.vm.$el.querySelector('.markdown').innerHTML.trim()).not.toEqual( + cell.source.join(''), + ); }); it('renders the markdown HTML', () => { - expect(vm.$el.querySelector('.markdown h1')).not.toBeNull(); + expect(wrapper.vm.$el.querySelector('.markdown h1')).not.toBeNull(); }); it('sanitizes Markdown output', async () => { @@ -68,11 +79,11 @@ describe('Markdown component', () => { }); await nextTick(); - expect(vm.$el.querySelector('a').getAttribute('href')).toBeNull(); + expect(wrapper.vm.$el.querySelector('a').getAttribute('href')).toBeNull(); }); it('sanitizes HTML', async () => { - const findLink = () => vm.$el.querySelector('.xss-link'); + const findLink = () => wrapper.vm.$el.querySelector('.xss-link'); Object.assign(cell, { source: ['<a href="test.js" data-remote=true data-type="script" class="xss-link">XSS</a>\n'], }); @@ -97,11 +108,11 @@ describe('Markdown component', () => { ["for embedded images, it doesn't", '![](data:image/jpeg;base64)\n', 'src="data:'], ["for images urls, it doesn't", '![](http://image.png)\n', 'src="http:'], ])('%s', async ([testMd, mustContain]) => { - vm = buildMarkdownComponent([testMd], '/raw/'); + wrapper = buildMarkdownComponent([testMd], '/raw/'); await nextTick(); - expect(vm.$el.innerHTML).toContain(mustContain); + expect(wrapper.vm.$el.innerHTML).toContain(mustContain); }); }); @@ -111,13 +122,13 @@ describe('Markdown component', () => { }); it('renders images and text', async () => { - vm = buildCellComponent(json.cells[0]); + wrapper = buildCellComponent(json.cells[0]); await nextTick(); - const images = vm.$el.querySelectorAll('img'); + const images = wrapper.vm.$el.querySelectorAll('img'); expect(images.length).toBe(5); - const columns = vm.$el.querySelectorAll('td'); + const columns = wrapper.vm.$el.querySelectorAll('td'); expect(columns.length).toBe(6); expect(columns[0].textContent).toEqual('Hello '); @@ -141,81 +152,93 @@ describe('Markdown component', () => { }); it('renders multi-line katex', async () => { - vm = buildCellComponent(json.cells[0]); + wrapper = buildCellComponent(json.cells[0]); await nextTick(); - expect(vm.$el.querySelector('.katex')).not.toBeNull(); + expect(wrapper.vm.$el.querySelector('.katex')).not.toBeNull(); }); it('renders inline katex', async () => { - vm = buildCellComponent(json.cells[1]); + wrapper = buildCellComponent(json.cells[1]); await nextTick(); - expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull(); + expect(wrapper.vm.$el.querySelector('p:first-child .katex')).not.toBeNull(); }); it('renders multiple inline katex', async () => { - vm = buildCellComponent(json.cells[1]); + wrapper = buildCellComponent(json.cells[1]); await nextTick(); - expect(vm.$el.querySelectorAll('p:nth-child(2) .katex')).toHaveLength(4); + expect(wrapper.vm.$el.querySelectorAll('p:nth-child(2) .katex')).toHaveLength(4); }); it('output cell in case of katex error', async () => { - vm = buildMarkdownComponent(['Some invalid $a & b$ inline formula $b & c$\n', '\n']); + wrapper = buildMarkdownComponent(['Some invalid $a & b$ inline formula $b & c$\n', '\n']); await nextTick(); // expect one paragraph with no katex formula in it - expect(vm.$el.querySelectorAll('p')).toHaveLength(1); - expect(vm.$el.querySelectorAll('p .katex')).toHaveLength(0); + expect(wrapper.vm.$el.querySelectorAll('p')).toHaveLength(1); + expect(wrapper.vm.$el.querySelectorAll('p .katex')).toHaveLength(0); }); it('output cell and render remaining formula in case of katex error', async () => { - vm = buildMarkdownComponent([ + wrapper = buildMarkdownComponent([ 'An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n', ]); await nextTick(); // expect one paragraph with no katex formula in it - expect(vm.$el.querySelectorAll('p')).toHaveLength(1); - expect(vm.$el.querySelectorAll('p .katex')).toHaveLength(1); + expect(wrapper.vm.$el.querySelectorAll('p')).toHaveLength(1); + expect(wrapper.vm.$el.querySelectorAll('p .katex')).toHaveLength(1); }); it('renders math formula in list object', async () => { - vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']); + wrapper = buildMarkdownComponent([ + "- list with inline $a=2$ inline formula $a' + b = c$\n", + '\n', + ]); await nextTick(); // expect one list with a katex formula in it - expect(vm.$el.querySelectorAll('li')).toHaveLength(1); - expect(vm.$el.querySelectorAll('li .katex')).toHaveLength(2); + expect(wrapper.vm.$el.querySelectorAll('li')).toHaveLength(1); + expect(wrapper.vm.$el.querySelectorAll('li .katex')).toHaveLength(2); }); it("renders math formula with tick ' in it", async () => { - vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']); + wrapper = buildMarkdownComponent([ + "- list with inline $a=2$ inline formula $a' + b = c$\n", + '\n', + ]); await nextTick(); // expect one list with a katex formula in it - expect(vm.$el.querySelectorAll('li')).toHaveLength(1); - expect(vm.$el.querySelectorAll('li .katex')).toHaveLength(2); + expect(wrapper.vm.$el.querySelectorAll('li')).toHaveLength(1); + expect(wrapper.vm.$el.querySelectorAll('li .katex')).toHaveLength(2); }); it('renders math formula with less-than-operator < in it', async () => { - vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b < c$\n', '\n']); + wrapper = buildMarkdownComponent([ + '- list with inline $a=2$ inline formula $a + b < c$\n', + '\n', + ]); await nextTick(); // expect one list with a katex formula in it - expect(vm.$el.querySelectorAll('li')).toHaveLength(1); - expect(vm.$el.querySelectorAll('li .katex')).toHaveLength(2); + expect(wrapper.vm.$el.querySelectorAll('li')).toHaveLength(1); + expect(wrapper.vm.$el.querySelectorAll('li .katex')).toHaveLength(2); }); it('renders math formula with greater-than-operator > in it', async () => { - vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b > c$\n', '\n']); + wrapper = buildMarkdownComponent([ + '- list with inline $a=2$ inline formula $a + b > c$\n', + '\n', + ]); await nextTick(); // expect one list with a katex formula in it - expect(vm.$el.querySelectorAll('li')).toHaveLength(1); - expect(vm.$el.querySelectorAll('li .katex')).toHaveLength(2); + expect(wrapper.vm.$el.querySelectorAll('li')).toHaveLength(1); + expect(wrapper.vm.$el.querySelectorAll('li .katex')).toHaveLength(2); }); }); }); diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js index 8bf049235a9..585cbb68eeb 100644 --- a/spec/frontend/notebook/cells/output/index_spec.js +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -1,12 +1,15 @@ import { mount } from '@vue/test-utils'; import json from 'test_fixtures/blob/notebook/basic.json'; import Output from '~/notebook/cells/output/index.vue'; +import MarkdownOutput from '~/notebook/cells/output/markdown.vue'; +import { relativeRawPath, markdownCellContent } from '../../mock_data'; describe('Output component', () => { let wrapper; const createComponent = (output) => { wrapper = mount(Output, { + provide: { relativeRawPath }, propsData: { outputs: [].concat(output), count: 1, @@ -95,6 +98,17 @@ describe('Output component', () => { }); }); + describe('Markdown output', () => { + beforeEach(() => { + const markdownType = { data: { 'text/markdown': markdownCellContent } }; + createComponent(markdownType); + }); + + it('renders a markdown component', () => { + expect(wrapper.findComponent(MarkdownOutput).props('rawCode')).toBe(markdownCellContent); + }); + }); + describe('default to plain text', () => { beforeEach(() => { const unknownType = json.cells[6]; diff --git a/spec/frontend/notebook/cells/output/markdown_spec.js b/spec/frontend/notebook/cells/output/markdown_spec.js new file mode 100644 index 00000000000..e3490ed3bea --- /dev/null +++ b/spec/frontend/notebook/cells/output/markdown_spec.js @@ -0,0 +1,44 @@ +import { mount } from '@vue/test-utils'; +import MarkdownOutput from '~/notebook/cells/output/markdown.vue'; +import Prompt from '~/notebook/cells/prompt.vue'; +import Markdown from '~/notebook/cells/markdown.vue'; +import { relativeRawPath, markdownCellContent } from '../../mock_data'; + +describe('markdown output cell', () => { + let wrapper; + + const createComponent = ({ count = 0, index = 0 } = {}) => { + wrapper = mount(MarkdownOutput, { + provide: { relativeRawPath }, + propsData: { + rawCode: markdownCellContent, + count, + index, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + const findPrompt = () => wrapper.findComponent(Prompt); + const findMarkdown = () => wrapper.findComponent(Markdown); + + it.each` + index | count | showOutput + ${0} | ${1} | ${true} + ${1} | ${2} | ${false} + ${2} | ${3} | ${false} + `('renders a prompt', ({ index, count, showOutput }) => { + createComponent({ count, index }); + expect(findPrompt().props()).toMatchObject({ count, showOutput, type: 'Out' }); + }); + + it('renders a Markdown component', () => { + expect(findMarkdown().props()).toMatchObject({ + cell: { source: markdownCellContent }, + hidePrompt: true, + }); + }); +}); diff --git a/spec/frontend/notebook/mock_data.js b/spec/frontend/notebook/mock_data.js new file mode 100644 index 00000000000..b1419e1256f --- /dev/null +++ b/spec/frontend/notebook/mock_data.js @@ -0,0 +1,2 @@ +export const relativeRawPath = '/test'; +export const markdownCellContent = ['# Test']; diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index 1ff5766b074..b1e9d8d1256 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -292,6 +292,42 @@ describe('Release edit component', () => { }); }); + describe('remove button state', () => { + describe('when there is only one link', () => { + beforeEach(() => { + factory({ + release: { + ...release, + assets: { + links: release.assets.links.slice(0, 1), + }, + }, + }); + }); + + it('remove asset link button should not be present', () => { + expect(wrapper.find('.remove-button').exists()).toBe(false); + }); + }); + + describe('when there are multiple links', () => { + beforeEach(() => { + factory({ + release: { + ...release, + assets: { + links: release.assets.links.slice(0, 2), + }, + }, + }); + }); + + it('remove asset link button should be visible', () => { + expect(wrapper.find('.remove-button').exists()).toBe(true); + }); + }); + }); + describe('empty state', () => { describe('when the release fetched from the API has no links', () => { beforeEach(() => { @@ -325,12 +361,6 @@ describe('Release edit component', () => { it('does not call the addEmptyAssetLink store method when the component is created', () => { expect(actions.addEmptyAssetLink).not.toHaveBeenCalled(); }); - - it('calls addEmptyAssetLink when the final link is deleted by the user', () => { - wrapper.find('.remove-button').vm.$emit('click'); - - expect(actions.addEmptyAssetLink).toHaveBeenCalledTimes(1); - }); }); }); }); |