UNPKG

chrome-devtools-frontend

Version:
373 lines (327 loc) • 16.1 kB
// Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js'; import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js'; import * as Marked from '../../../third_party/marked/marked.js'; import * as Lit from '../../lit/lit.js'; import * as MarkdownView from './markdown_view.js'; const {html} = Lit; interface TestToken { type: string; tokens?: Marked.Marked.Token[]; text?: string; href?: string; items?: Object[]; depth?: number; } function getFakeToken(token: TestToken): Marked.Marked.Token { return token as unknown as Marked.Marked.Token; } function renderTemplateResult(templateResult: Lit.TemplateResult): HTMLElement { const container = document.createElement('container'); Lit.render(templateResult, container); return container; } describeWithEnvironment('MarkdownView', () => { describe('tokenizer', () => { it('tokenizers links in single quotes', () => { assert.deepEqual(Marked.Marked.lexer('\'https://example.test\''), [ { raw: '\'https://example.test\'', text: '\'https://example.test\'', tokens: [ { raw: '\'', text: '&#39;', type: 'text', }, { href: 'https://example.test', raw: 'https://example.test', text: 'https://example.test', tokens: [ { raw: 'https://example.test', text: 'https://example.test', type: 'text', }, ], type: 'link', }, { raw: '\'', text: '&#39;', type: 'text', }, ], type: 'paragraph', }, ] as unknown as Marked.Marked.TokensList); }); }); describe('MarkdownLitRenderer renderToken', () => { const renderer = new MarkdownView.MarkdownView.MarkdownLitRenderer(); it('wraps paragraph tokens in <p> tags', () => { const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'paragraph', tokens: []}))); assert.exists(container.querySelector('p')); }); it('wraps an unordered list token in <ul> tags', () => { const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'list', items: []}))); assert.exists(container.querySelector('ul')); }); it('wraps list items in <li> tags', () => { const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'list_item', tokens: []}))); assert.exists(container.querySelector('li')); }); it('wraps a codespan token in <code> tags', () => { const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'codespan', text: 'const foo = 42;'}))); const code = container.querySelector('code'); assert.exists(code); assert.deepEqual(code.textContent, 'const foo = 42;'); }); it('renders childless text tokens as-is', () => { const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'text', text: 'Simple text token'}))); assert.lengthOf(container.childTextNodes(), 1); assert.deepEqual(container.childTextNodes()[0].textContent, 'Simple text token'); }); it('renders nested text tokens correctly', () => { const container = renderTemplateResult(renderer.renderToken(getFakeToken({ type: 'text', text: 'This text should not be rendered. Only the subtokens!', tokens: [ getFakeToken({type: 'text', text: 'Nested raw text'}), getFakeToken({type: 'codespan', text: 'and a nested codespan to boot'}), ], }))); assert.notInclude(container.textContent, 'This text should not be rendered. Only the subtokens!'); assert.include(container.textContent, 'Nested raw text'); assert.exists(container.querySelector('code')); assert.deepEqual(container.querySelector('code')?.textContent, 'and a nested codespan to boot'); }); it('throws an error for invalid or unsupported token types', () => { assert.throws(() => renderer.renderToken(getFakeToken({type: 'no_way_this_is_a_valid_markdown_token'}))); }); it('renders link with valid key', () => { MarkdownView.MarkdownLinksMap.markdownLinks.set('exampleLink', 'https://web.dev/'); const renderResult = renderer.renderToken(getFakeToken({type: 'link', text: 'learn more', href: 'exampleLink'})).strings.join(''); assert.isTrue(renderResult.includes('<devtools-markdown-link')); }); it('throws an error if invalid link key is provided', () => { assert.throws(() => MarkdownView.MarkdownLinksMap.getMarkdownLink('testErrorLink')); }); it('renders icon with valid key', () => { MarkdownView.MarkdownImagesMap.markdownImages.set('testExampleImage', { src: 'devices', isIcon: true, }); const renderResult = renderer.renderToken(getFakeToken({type: 'image', text: 'phone', href: 'testExampleImage'})).strings.join(''); assert.isTrue(renderResult.includes('<devtools-markdown-image')); }); it('renders image with valid key', () => { MarkdownView.MarkdownImagesMap.markdownImages.set('exampleImage', { src: 'Images/phone-logo.png', isIcon: false, }); const renderResult = renderer.renderToken(getFakeToken({type: 'image', text: 'phone', href: 'exampleImage'})).strings.join(''); assert.isTrue(renderResult.includes('<devtools-markdown-image')); }); it('throws an error if invalid image key is provided', () => { assert.throws(() => MarkdownView.MarkdownImagesMap.getMarkdownImage('testErrorImageLink')); }); it('renders a heading correctly', () => { const renderResult = renderer.renderToken(getFakeToken({type: 'heading', text: 'a heading text', depth: 3})).strings.join(''); assert.isTrue(renderResult.includes('<h3')); }); it('renders strong correctly', () => { const renderResult = renderer.renderToken(getFakeToken({type: 'strong', text: 'a strong text'})).strings.join(''); assert.isTrue(renderResult.includes('<strong')); }); it('renders em correctly', () => { const renderResult = renderer.renderToken(getFakeToken({type: 'em', text: 'em text'})).strings.join(''); assert.isTrue(renderResult.includes('<em')); }); it('sets custom classes on the token types', () => { renderer.addCustomClasses({em: 'custom-class'}); const renderResult = renderer.renderToken(getFakeToken({type: 'em', text: 'em text'})); const container = renderTemplateResult(renderResult); assert.isTrue( container.querySelector('em')?.classList.contains('custom-class'), 'Expected custom-class to be applied'); }); }); describe('MarkdownInsightRenderer renderToken', () => { const renderer = new MarkdownView.MarkdownView.MarkdownInsightRenderer(); it('renders link as texts', () => { const result = renderer.renderToken({type: 'link', text: 'learn more', href: 'https://example.test'} as Marked.Marked.Token); assert.strictEqual(result.values[0], 'learn more'); }); it('renders link urls as texts', () => { const result = renderer.renderToken({type: 'link', href: 'https://example.test'} as Marked.Marked.Token); assert.strictEqual(result.values[0], 'https://example.test'); }); it('does not render URLs with "javascript:"', () => { const result = renderer.renderToken( {type: 'link', text: 'learn more', href: 'javascript:alert("test")'} as Marked.Marked.Token); assert.isUndefined(result.values[0]); }); it('does not render chrome:// URLs', () => { const result = renderer.renderToken({type: 'link', text: 'learn more', href: 'chrome://settings'} as Marked.Marked.Token); assert.isUndefined(result.values[0]); }); it('does not render invalid URLs', () => { const result = renderer.renderToken({type: 'link', text: 'learn more', href: '123'} as Marked.Marked.Token); assert.isUndefined(result.values[0]); }); it('renders images as text', () => { const result = renderer.renderToken( {type: 'image', text: 'learn more', href: 'https://example.test'} as Marked.Marked.Token); assert.strictEqual(result.values[0], 'learn more'); }); it('renders image urls as text', () => { const result = renderer.renderToken({type: 'image', href: 'https://example.test'} as Marked.Marked.Token); assert.strictEqual(result.values[0], 'https://example.test'); }); it('renders headings as headings with the `insight` class', () => { const renderResult = renderer.renderToken(getFakeToken({type: 'heading', text: 'a heading text', depth: 3})); const container = renderTemplateResult(renderResult); assert.isTrue( container.querySelector('h3')?.classList.contains('insight'), 'Expected `insight`-class to be applied'); }); it('renders unsupported tokens', () => { const result = renderer.renderToken({type: 'html', raw: '<!DOCTYPE html>'} as Marked.Marked.Token); assert(result.values.join('').includes('<!DOCTYPE html>')); }); it('detects language but default to provided', () => { let result = renderer.detectCodeLanguage({text: 'const int foo = "bar"', lang: 'cpp'} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'cpp'); result = renderer.detectCodeLanguage({text: '', lang: 'cpp'} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'cpp'); }); it('detects JavaScript language', () => { let result = renderer.detectCodeLanguage({text: 'const t = 2', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'js'); result = renderer.detectCodeLanguage({text: 'let t = 2', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'js'); result = renderer.detectCodeLanguage({text: 'var t = 2', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'js'); result = renderer.detectCodeLanguage({text: 'function t(){}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'js'); result = renderer.detectCodeLanguage({text: 'async function t(){}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'js'); result = renderer.detectCodeLanguage( {text: 'import puppeteer from "puppeteer-core"', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'js'); }); it('doesn`t detect JavaScript language', () => { let result = renderer.detectCodeLanguage({text: 'constant F', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, ''); result = renderer.detectCodeLanguage({text: 'variable', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, ''); result = renderer.detectCodeLanguage( {text: 'functions are better then classes', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, ''); result = renderer.detectCodeLanguage( {text: 'asynchronous code it hard to understand', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, ''); }); it('detects CSS language', () => { let result = renderer.detectCodeLanguage({text: '.myClass {}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'css'); result = renderer.detectCodeLanguage({text: '.myClass{}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'css'); result = renderer.detectCodeLanguage({text: 'my-component {}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'css'); result = renderer.detectCodeLanguage({text: 'my-component::after {}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'css'); result = renderer.detectCodeLanguage({text: '.foo::[name="bar"] {}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, 'css'); }); it('doesn`t detects JSON as CSS language', () => { let result = renderer.detectCodeLanguage({text: '{ "test": "test" }', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, ''); result = renderer.detectCodeLanguage({text: '{}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, ''); result = renderer.detectCodeLanguage({text: '{\n"test": "test"\n}', lang: ''} as Marked.Marked.Tokens.Code); assert.strictEqual(result, ''); }); }); const paragraphText = 'Single paragraph with a sentence of text and some list items to test that the component works end-to-end.'; const listItemTexts = ['Simple unordered list item 1', 'Simple unordered list item 2']; const markdownString = ` ${paragraphText} * ${listItemTexts[0]} * ${listItemTexts[1]} `; const renderString = (string: string, selector: 'p'|'code'|'devtools-code-block', renderer?: MarkdownView.MarkdownView.MarkdownLitRenderer) => { const component = new MarkdownView.MarkdownView.MarkdownView(); renderElementIntoDOM(component); component.data = {tokens: Marked.Marked.lexer(string), renderer}; assert.isNotNull(component.shadowRoot); const element = component.shadowRoot.querySelector(selector); return element ? element : document.createElement('span'); }; describe('component', () => { it('renders basic markdown correctly', () => { const component = new MarkdownView.MarkdownView.MarkdownView(); renderElementIntoDOM(component); component.data = {tokens: Marked.Marked.lexer(markdownString)}; assert.isNotNull(component.shadowRoot); const paragraphs = Array.from(component.shadowRoot.querySelectorAll('p')); assert.lengthOf(paragraphs, 1); assert.strictEqual(paragraphs[0].innerText, paragraphText); const listItems = Array.from(component.shadowRoot.querySelectorAll('li')); assert.lengthOf(listItems, 2); assert.deepEqual(listItems.map(item => item.textContent), listItemTexts); }); it('renders a codeblock', () => { const codeBlock = renderString( `\`\`\` console.log('test') \`\`\``, 'devtools-code-block'); assert.strictEqual((codeBlock as MarkdownView.CodeBlock.CodeBlock).code, 'console.log(\'test\')'); }); it('renders using a custom renderer', () => { const codeBlock = renderString('`console.log()`', 'code', new class extends MarkdownView.MarkdownView.MarkdownLitRenderer { override templateForToken(token: Marked.Marked.Token): Lit.TemplateResult|null { if (token.type === 'codespan') { return html`<code>overriden</code>`; } return super.templateForToken(token as Marked.Marked.MarkedToken); } }()); assert.strictEqual(codeBlock.innerText, 'overriden'); }); }); describe('escaping', () => { it('renders basic escaped non-html tag', () => { const paragraph = renderString('<123>', 'p'); assert.strictEqual(paragraph.innerText, '<123>'); }); it('renders all unescaped chars', () => { const paragraph = renderString('<>&\'"', 'p'); assert.strictEqual(paragraph.innerText, '<>&\'"'); }); it('renders all escaped chars', () => { const paragraph = renderString('&lt;&gt;&amp;&#39;&quot;', 'p'); assert.strictEqual(paragraph.innerText, '<>&\'"'); }); it('renders basic escaped tag inside codespan', () => { const codeBlock = renderString('`<123>`', 'code'); assert.strictEqual(codeBlock.innerText, '<123>'); }); }); });