Feature: Add gist card (#3064)

* Feature: Add gist card

* dev

* dev

* dev

* dev

* dev

* dev

* e2e

* e2e test timeout
This commit is contained in:
Alexandr Garbuzov 2023-08-14 12:28:01 +03:00 committed by GitHub
parent 48830adefa
commit fe901dd337
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 614 additions and 3 deletions

81
api/gist.js Normal file
View file

@ -0,0 +1,81 @@
import {
clampValue,
CONSTANTS,
renderError,
parseBoolean,
} from "../src/common/utils.js";
import { isLocaleAvailable } from "../src/translations.js";
import { renderGistCard } from "../src/cards/gist-card.js";
import { fetchGist } from "../src/fetchers/gist-fetcher.js";
export default async (req, res) => {
const {
id,
title_color,
icon_color,
text_color,
bg_color,
theme,
cache_seconds,
locale,
border_radius,
border_color,
show_owner,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
if (locale && !isLocaleAvailable(locale)) {
return res.send(renderError("Something went wrong", "Language not found"));
}
try {
const gistData = await fetchGist(id);
let cacheSeconds = clampValue(
parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10),
CONSTANTS.FOUR_HOURS,
CONSTANTS.ONE_DAY,
);
cacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
: cacheSeconds;
/*
if star count & fork count is over 1k then we are kFormating the text
and if both are zero we are not showing the stats
so we can just make the cache longer, since there is no need to frequent updates
*/
const stars = gistData.starsCount;
const forks = gistData.forksCount;
const isBothOver1K = stars > 1000 && forks > 1000;
const isBothUnder1 = stars < 1 && forks < 1;
if (!cache_seconds && (isBothOver1K || isBothUnder1)) {
cacheSeconds = CONSTANTS.FOUR_HOURS;
}
res.setHeader(
"Cache-Control",
`max-age=${
cacheSeconds / 2
}, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
);
return res.send(
renderGistCard(gistData, {
title_color,
icon_color,
text_color,
bg_color,
theme,
border_radius,
border_color,
locale: locale ? locale.toLowerCase() : null,
show_owner: parseBoolean(show_owner),
}),
);
} catch (err) {
res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses.
return res.send(renderError(err.message, err.secondaryMessage));
}
};

View file

@ -97,8 +97,11 @@ Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of
- [GitHub Extra Pins](#github-extra-pins)
- [Usage](#usage)
- [Demo](#demo)
- [Top Languages Card](#top-languages-card)
- [GitHub Gist Pins](#github-gist-pins)
- [Usage](#usage-1)
- [Demo](#demo-1)
- [Top Languages Card](#top-languages-card)
- [Usage](#usage-2)
- [Language stats algorithm](#language-stats-algorithm)
- [Exclude individual repositories](#exclude-individual-repositories)
- [Hide individual languages](#hide-individual-languages)
@ -108,9 +111,9 @@ Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of
- [Donut Vertical Chart Language Card Layout](#donut-vertical-chart-language-card-layout)
- [Pie Chart Language Card Layout](#pie-chart-language-card-layout)
- [Hide Progress Bars](#hide-progress-bars)
- [Demo](#demo-1)
- [Wakatime Stats Card](#wakatime-stats-card)
- [Demo](#demo-2)
- [Wakatime Stats Card](#wakatime-stats-card)
- [Demo](#demo-3)
- [All Demos](#all-demos)
- [Quick Tip (Align The Cards)](#quick-tip-align-the-cards)
- [Deploy on your own](#deploy-on-your-own)
@ -328,6 +331,10 @@ You can provide multiple comma-separated values in the bg\_color option to rende
* `show_owner` - Shows the repo's owner name *(boolean)*. Default: `false`.
#### Gist Card Exclusive Options
* `show_owner` - Shows the gist's owner name *(boolean)*. Default: `false`.
#### Language Card Exclusive Options
* `hide` - Hides the languages specified from the card *(Comma-separated values)*. Default: `[] (blank array)`.
@ -384,6 +391,28 @@ Use [show\_owner](#repo-card-exclusive-options) query option to include the repo
![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats\&show_owner=true)
# GitHub Gist Pins
GitHub gist pins allow you to pin gists in your GitHub profile using a GitHub readme profile.
### Usage
Copy-paste this code into your readme and change the links.
Endpoint: `api/gist?id=bbfce31e0217a3689c8d961a356cb10d`
```md
[![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)](https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d/)
```
### Demo
![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)
Use [show\_owner](#gist-card-exclusive-options) query option to include the gist's owner username
![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d\&show_owner=true)
# Top Languages Card
The top languages card shows a GitHub user's most frequently used languages.
@ -592,6 +621,14 @@ Choose from any of the [default themes](#themes)
![Customized Card](https://github-readme-stats.vercel.app/api/pin?username=anuraghazra\&repo=github-readme-stats\&title_color=fff\&icon_color=f9f9f9\&text_color=9f9f9f\&bg_color=151515)
* Gist card
![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)
* Customizing gist card
![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=calm)
* Top languages
![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)

180
src/cards/gist-card.js Normal file
View file

@ -0,0 +1,180 @@
// @ts-check
import {
getCardColors,
parseEmojis,
wrapTextMultiline,
encodeHTML,
kFormatter,
measureText,
flexLayout,
} from "../common/utils.js";
import Card from "../common/Card.js";
import { icons } from "../common/icons.js";
/** Import language colors.
*
* @description Here we use the workaround found in
* https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node
* since vercel is using v16.14.0 which does not yet support json imports without the
* --experimental-json-modules flag.
*/
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const languageColors = require("../common/languageColors.json"); // now works
const ICON_SIZE = 16;
const CARD_DEFAULT_WIDTH = 400;
const HEADER_MAX_LENGTH = 35;
/**
* Creates a node to display the primary programming language of the gist.
*
* @param {string} langName Language name.
* @param {string} langColor Language color.
* @returns {string} Language display SVG object.
*/
const createLanguageNode = (langName, langColor) => {
return `
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
<text data-testid="lang-name" class="gray" x="15">${langName}</text>
</g>
`;
};
/**
* Creates an icon with label to display gist stats like forks, stars, etc.
*
* @param {string} icon The icon to display.
* @param {number|string} label The label to display.
* @param {string} testid The testid to assign to the label.
* @returns {string} Icon with label SVG object.
*/
const iconWithLabel = (icon, label, testid) => {
if (typeof label === "number" && label <= 0) return "";
const iconSvg = `
<svg
class="icon"
y="-12"
viewBox="0 0 16 16"
version="1.1"
width="${ICON_SIZE}"
height="${ICON_SIZE}"
>
${icon}
</svg>
`;
const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
};
/**
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
* @typedef {import('../fetchers/types').GistData} GistData Gist data.
*/
/**
* Render gist card.
*
* @param {GistData} gistData Gist data.
* @param {Partial<GistCardOptions>} options Gist card options.
* @returns {string} Gist card.
*/
const renderGistCard = (gistData, options = {}) => {
const { name, nameWithOwner, description, language, starsCount, forksCount } =
gistData;
const {
title_color,
icon_color,
text_color,
bg_color,
theme,
border_radius,
border_color,
show_owner = false,
} = options;
// returns theme based colors with proper overrides and defaults
const { titleColor, textColor, iconColor, bgColor, borderColor } =
getCardColors({
title_color,
icon_color,
text_color,
bg_color,
border_color,
theme,
});
const lineWidth = 59;
const linesLimit = 10;
const desc = parseEmojis(description || "No description provided");
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
const descriptionLines = multiLineDescription.length;
const descriptionSvg = multiLineDescription
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
.join("");
const lineHeight = descriptionLines > 3 ? 12 : 10;
const height =
(descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight;
const totalStars = kFormatter(starsCount);
const totalForks = kFormatter(forksCount);
const svgStars = iconWithLabel(icons.star, totalStars, "starsCount");
const svgForks = iconWithLabel(icons.fork, totalForks, "forksCount");
const languageName = language || "Unspecified";
const languageColor = languageColors[languageName] || "#858585";
const svgLanguage = createLanguageNode(languageName, languageColor);
const starAndForkCount = flexLayout({
items: [svgLanguage, svgStars, svgForks],
sizes: [
measureText(languageName, 12),
ICON_SIZE + measureText(`${totalStars}`, 12),
ICON_SIZE + measureText(`${totalForks}`, 12),
],
gap: 25,
}).join("");
const header = show_owner ? nameWithOwner : name;
const card = new Card({
defaultTitle:
header.length > HEADER_MAX_LENGTH
? `${header.slice(0, HEADER_MAX_LENGTH)}...`
: header,
titlePrefixIcon: icons.gist,
width: CARD_DEFAULT_WIDTH,
height,
border_radius,
colors: {
titleColor,
textColor,
iconColor,
bgColor,
borderColor,
},
});
card.setCSS(`
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.icon { fill: ${iconColor} }
`);
return card.render(`
<text class="description" x="25" y="-5">
${descriptionSvg}
</text>
<g transform="translate(30, ${height - 75})">
${starAndForkCount}
</g>
`);
};
export { renderGistCard, HEADER_MAX_LENGTH };
export default renderGistCard;

View file

@ -57,3 +57,7 @@ type WakaTimeOptions = CommonOptions & {
layout: "compact" | "normal";
langs_count: number;
};
export type GistCardOptions = CommonOptions & {
show_owner: boolean;
};

View file

@ -11,6 +11,7 @@ const icons = {
reviews: `<path fill-rule="evenodd" d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/>`,
discussions_started: `<path fill-rule="evenodd" d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z" />`,
discussions_answered: `<path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />`,
gist: `<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />`,
};
/**

View file

@ -0,0 +1,106 @@
// @ts-check
import { request } from "../common/utils.js";
import { retryer } from "../common/retryer.js";
/**
* @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
* @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
*/
const QUERY = `
query gistInfo($gistName: String!) {
viewer {
gist(name: $gistName) {
description
owner {
login
}
stargazerCount
forks {
totalCount
}
files {
name
language {
name
}
size
}
}
}
}
`;
/**
* Gist data fetcher.
*
* @param {AxiosRequestHeaders} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<AxiosResponse>} The response.
*/
const fetcher = async (variables, token) => {
return await request(
{ query: QUERY, variables },
{ Authorization: `token ${token}` },
);
};
/**
* @typedef {import('./types').GistData} GistData Gist data.
*/
/**
* Fetch GitHub gist information by given username and ID.
*
* @param {string} id Github gist ID.
* @returns {Promise<GistData>} Gist data.
*/
const fetchGist = async (id) => {
const res = await retryer(fetcher, { gistName: id });
if (res.data.errors) throw new Error(res.data.errors[0].message);
const data = res.data.data.viewer.gist;
return {
name: data.files[Object.keys(data.files)[0]].name,
nameWithOwner: `${data.owner.login}/${
data.files[Object.keys(data.files)[0]].name
}`,
description: data.description,
language: calculatePrimaryLanguage(data.files),
starsCount: data.stargazerCount,
forksCount: data.forks.totalCount,
};
};
/**
* @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file.
*/
/**
* This function calculates the primary language of a gist by files size.
*
* @param {GistFile[]} files Files.
* @returns {string} Primary language.
*/
const calculatePrimaryLanguage = (files) => {
const languages = {};
for (const file of files) {
if (file.language) {
if (languages[file.language.name]) {
languages[file.language.name] += file.size;
} else {
languages[file.language.name] = file.size;
}
}
}
let primaryLanguage = Object.keys(languages)[0];
for (const language in languages) {
if (languages[language] > languages[primaryLanguage]) {
primaryLanguage = language;
}
}
return primaryLanguage;
};
export { fetchGist };
export default fetchGist;

View file

@ -1,3 +1,12 @@
export type GistData = {
name: string;
nameWithOwner: string;
description: string;
language: string | null;
starsCount: number;
forksCount: number;
};
export type RepositoryData = {
name: string;
nameWithOwner: string;

View file

@ -9,10 +9,13 @@ import { renderRepoCard } from "../../src/cards/repo-card.js";
import { renderStatsCard } from "../../src/cards/stats-card.js";
import { renderTopLanguages } from "../../src/cards/top-languages-card.js";
import { renderWakatimeCard } from "../../src/cards/wakatime-card.js";
import { renderGistCard } from "../../src/cards/gist-card.js";
import { expect, describe, beforeAll, test } from "@jest/globals";
const REPO = "curly-fiesta";
const USER = "catelinemnemosyne";
const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
const STATS_DATA = {
name: "Cateline Mnemosyne",
totalPRs: 2,
@ -81,6 +84,23 @@ const REPOSITORY_DATA = {
starCount: 1,
};
/**
* @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type.
*/
/**
* @type {GistData}
*/
const GIST_DATA = {
name: "link.txt",
nameWithOwner: "qwerty541/link.txt",
description:
"Trying to access this path on Windown 10 ver. 1803+ will breaks NTFS",
language: "Text",
starsCount: 1,
forksCount: 0,
};
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
describe("Fetch Cards", () => {
@ -177,4 +197,26 @@ describe("Fetch Cards", () => {
// Check if Repo card from deployment matches the local Repo card.
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
}, 15000);
test("retrieve gist card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
// Check if the Vercel preview instance Gist function is up and running.
await expect(
axios.get(
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
),
).resolves.not.toThrow();
// Get local gist card.
const localGistCardSVG = renderGistCard(GIST_DATA);
// Get the Vercel preview gist card response.
const serverGistSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
);
// Check if Gist card from deployment matches the local Gist card.
expect(serverGistSvg.data).toEqual(localGistCardSVG);
}, 15000);
});

72
tests/fetchGist.test.js Normal file
View file

@ -0,0 +1,72 @@
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { expect, it, describe, afterEach } from "@jest/globals";
import { fetchGist } from "../src/fetchers/gist-fetcher.js";
const gist_data = {
data: {
viewer: {
gist: {
description:
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
owner: {
login: "Yizack",
},
stargazerCount: 33,
forks: {
totalCount: 11,
},
files: [
{
name: "countries.json",
language: {
name: "JSON",
},
size: 85858,
},
],
},
},
},
};
const gist_errors_data = {
errors: [
{
message: "Some test GraphQL error",
},
],
};
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test fetchGist", () => {
it("should fetch gist correctly", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
let gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d");
expect(gist).toStrictEqual({
name: "countries.json",
nameWithOwner: "Yizack/countries.json",
description:
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
language: "JSON",
starsCount: 33,
forksCount: 11,
});
});
it("should throw error if reaponse contains them", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, gist_errors_data);
await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow(
"Some test GraphQL error",
);
});
});

View file

@ -0,0 +1,79 @@
import { renderGistCard } from "../src/cards/gist-card";
import { describe, expect, it } from "@jest/globals";
import { queryByTestId } from "@testing-library/dom";
import "@testing-library/jest-dom";
/**
* @type {import("../src/fetchers/gist-fetcher").GistData}
*/
const data = {
name: "test",
nameWithOwner: "anuraghazra/test",
description: "Small test repository with different Python programs.",
language: "Python",
starsCount: 163,
forksCount: 19,
};
describe("test renderGistCard", () => {
it("should render correctly", () => {
document.body.innerHTML = renderGistCard(data);
const [header] = document.getElementsByClassName("header");
expect(header).toHaveTextContent("test");
expect(header).not.toHaveTextContent("anuraghazra");
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"Small test repository with different Python programs.",
);
expect(queryByTestId(document.body, "starsCount")).toHaveTextContent("163");
expect(queryByTestId(document.body, "forksCount")).toHaveTextContent("19");
expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
"Python",
);
expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
"fill",
"#3572A5",
);
});
it("should display username in title if show_owner is true", () => {
document.body.innerHTML = renderGistCard(data, { show_owner: true });
const [header] = document.getElementsByClassName("header");
expect(header).toHaveTextContent("anuraghazra/test");
});
it("should trim header if name is too long", () => {
document.body.innerHTML = renderGistCard({
...data,
name: "some-really-long-repo-name-for-test-purposes",
});
const [header] = document.getElementsByClassName("header");
expect(header).toHaveTextContent("some-really-long-repo-name-for-test...");
});
it("should trim description if description os too long", () => {
document.body.innerHTML = renderGistCard({
...data,
description:
"The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
});
expect(
document.getElementsByClassName("description")[0].children[0].textContent,
).toBe("The quick brown fox jumps over the lazy dog is an");
expect(
document.getElementsByClassName("description")[0].children[1].textContent,
).toBe("English-language pangram—a sentence that contains all");
});
it("should not trim description if it is short", () => {
document.body.innerHTML = renderGistCard({
...data,
description: "Small text should not trim",
});
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"Small text should not trim",
);
});
});