From 3f0855d4e79025f5347d7b42f4a0e044ba3bbc16 Mon Sep 17 00:00:00 2001 From: Arnau Casau <47946624+arnaucasau@users.noreply.github.com> Date: Mon, 25 Mar 2024 15:49:45 +0100 Subject: [PATCH] Add `generateApiComponents.ts` and tests (#1074) Part of https://github.com/Qiskit/documentation/issues/1008 This PR is a precursor of #1026, and it creates a new script named `generateApiComponents.ts` with some tests. This script will have all the logic to generate the MDX components, but, in the meantime, this PR adds some functions that can be tested independently by just receiving, as parameters, a list of props or HTML code. It also copies over the functions `prepareGitHubLink` and `findByText` from `processHtml.ts` --- scripts/lib/api/generateApiComponents.test.ts | 202 ++++++++++++++++++ scripts/lib/api/generateApiComponents.ts | 144 +++++++++++++ scripts/lib/api/processHtml.test.ts | 122 ++--------- scripts/lib/api/processHtml.ts | 47 +--- scripts/lib/stringUtils.test.ts | 6 + scripts/lib/stringUtils.ts | 6 + scripts/lib/testUtils.ts | 36 ++++ 7 files changed, 420 insertions(+), 143 deletions(-) create mode 100644 scripts/lib/api/generateApiComponents.test.ts create mode 100644 scripts/lib/api/generateApiComponents.ts create mode 100644 scripts/lib/testUtils.ts diff --git a/scripts/lib/api/generateApiComponents.test.ts b/scripts/lib/api/generateApiComponents.test.ts new file mode 100644 index 00000000000..d4e3d8e2290 --- /dev/null +++ b/scripts/lib/api/generateApiComponents.test.ts @@ -0,0 +1,202 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2024. +// +// This code is licensed under the Apache License, Version 2.0. You may +// obtain a copy of this license in the LICENSE file in the root directory +// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +// +// Any modifications or derivative works of this code must retain this +// copyright notice, and modified files need to carry a notice indicating +// that they have been altered from the originals. + +import { expect, test } from "@jest/globals"; + +import { + ComponentProps, + prepareGitHubLink, + htmlSignatureToMd, + addExtraSignatures, + createOpeningTag, +} from "./generateApiComponents"; +import { APOSTROPHE_HEX_CODE } from "../stringUtils"; +import { CheerioDoc } from "../testUtils"; + +const RAW_SIGNATURE_EXAMPLE = `Estimator.run(circuits, observables, parameter_values=None, **kwargs)`; + +test("htmlSignatureToMd", async () => { + const result = await htmlSignatureToMd(RAW_SIGNATURE_EXAMPLE); + expect(result).toEqual( + `Estimator.run(circuits, observables, parameter_values=None, **kwargs)`, + ); +}); + +describe("addExtraSignatures()", () => { + test("Function with overload signatures", () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "first signature", + }; + const extraRawSignatures = [ + { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "second signature", + }, + ]; + + const resultExpected = { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "first signature", + extraRawSignatures: ["second signature"], + }; + addExtraSignatures(componentProps, extraRawSignatures); + expect(componentProps).toEqual(resultExpected); + }); + + test("Function without overload signatures", () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "first signature", + }; + const extraRawSignatures: ComponentProps[] = []; + + addExtraSignatures(componentProps, extraRawSignatures); + expect(componentProps).toEqual(componentProps); + }); +}); + +describe("createOpeningTag()", () => { + test("Create Function tag with some props", async () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + name: "run", + rawSignature: RAW_SIGNATURE_EXAMPLE, + }; + + const tag = await createOpeningTag("Function", componentProps); + expect(tag).toEqual(` + `); + }); + + test("Create Function tag with overloaded signatures", async () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + name: "run", + rawSignature: RAW_SIGNATURE_EXAMPLE, + extraRawSignatures: [RAW_SIGNATURE_EXAMPLE, RAW_SIGNATURE_EXAMPLE], + }; + + const tag = await createOpeningTag("Function", componentProps); + expect(tag).toEqual(` + `); + }); + + test("Create Attribute tag with default value and type hint", async () => { + const componentProps = { + id: "qiskit.circuit.QuantumCircuit.instance", + name: "instance", + attributeTypeHint: "str | None", + attributeValue: "None", + }; + + const tag = await createOpeningTag("Attribute", componentProps); + expect(tag).toEqual(` + `); + }); + + test("Create Class tag without props", async () => { + const componentProps = { + id: "qiskit.circuit.Sampler", + }; + + const tag = await createOpeningTag("Class", componentProps); + expect(tag).toEqual(` + `); + }); +}); + +describe("prepareGitHubLink()", () => { + test("no link", () => { + const html = `None)#`; + const doc = CheerioDoc.load(html); + const result = prepareGitHubLink(doc.$main, false); + expect(result).toEqual(undefined); + doc.expectHtml(html); + }); + + test("link from sphinx.ext.viewcode", () => { + const doc = CheerioDoc.load( + `None)[source]#`, + ); + const result = prepareGitHubLink(doc.$main, false); + expect(result).toEqual(`https://ibm.com/my_link`); + doc.expectHtml( + `None)#`, + ); + }); + + test("link from sphinx.ext.linkcode", () => { + const doc = CheerioDoc.load( + `None)[source]#`, + ); + const result = prepareGitHubLink(doc.$main, false); + expect(result).toEqual( + `https://github.com/Qiskit/qiskit/blob/stable/1.0/qiskit/utils/deprecation.py#L24-L101`, + ); + doc.expectHtml( + `None)#`, + ); + }); + + test("method link only used when it has line numbers", () => { + const withLinesDoc = CheerioDoc.load( + `)[source]`, + ); + const withoutLinesDoc = CheerioDoc.load( + `)[source]`, + ); + const withLinesResult = prepareGitHubLink(withLinesDoc.$main, true); + const withoutLinesResult = prepareGitHubLink(withoutLinesDoc.$main, true); + + expect(withLinesResult).toEqual( + `https://github.com/Qiskit/qiskit-ibm-provider/tree/stable/0.10/qiskit_ibm_provider/transpiler/passes/scheduling/block_base_padder.py#L91-L117`, + ); + expect(withoutLinesResult).toEqual(undefined); + + const strippedHtml = `)`; + withLinesDoc.expectHtml(strippedHtml); + withoutLinesDoc.expectHtml(strippedHtml); + }); +}); diff --git a/scripts/lib/api/generateApiComponents.ts b/scripts/lib/api/generateApiComponents.ts new file mode 100644 index 00000000000..8e43bba2772 --- /dev/null +++ b/scripts/lib/api/generateApiComponents.ts @@ -0,0 +1,144 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2024. +// +// This code is licensed under the Apache License, Version 2.0. You may +// obtain a copy of this license in the LICENSE file in the root directory +// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +// +// Any modifications or derivative works of this code must retain this +// copyright notice, and modified files need to carry a notice indicating +// that they have been altered from the originals. + +import { CheerioAPI, Cheerio } from "cheerio"; +import { unified } from "unified"; +import rehypeParse from "rehype-parse"; +import rehypeRemark from "rehype-remark"; +import remarkStringify from "remark-stringify"; + +import { APOSTROPHE_HEX_CODE } from "../stringUtils"; + +export type ComponentProps = { + id: string; + name?: string; + attributeTypeHint?: string; + attributeValue?: string; + githubSourceLink?: string; + rawSignature?: string; + extraRawSignatures?: string[]; +}; + +// ------------------------------------------------------------------ +// Generate MDX components +// ------------------------------------------------------------------ + +/** + * Creates the opening tag of the API components. The function sets all possible + * props values even if they are empty or undefined. All the props without value + * will be removed when generating the markdown file in `htmlToMd.ts`. + */ +export async function createOpeningTag( + tagName: string, + props: ComponentProps, +): Promise { + const attributeTypeHint = props.attributeTypeHint?.replaceAll( + "'", + APOSTROPHE_HEX_CODE, + ); + const attributeValue = props.attributeValue?.replaceAll( + "'", + APOSTROPHE_HEX_CODE, + ); + const signature = await htmlSignatureToMd(props.rawSignature!); + const extraSignatures: string[] = []; + for (const sig of props.extraRawSignatures ?? []) { + extraSignatures.push( + `${APOSTROPHE_HEX_CODE}${await htmlSignatureToMd( + sig!, + )}${APOSTROPHE_HEX_CODE}`, + ); + } + + return `<${tagName} + id='${props.id}' + name='${props.name}' + attributeTypeHint='${attributeTypeHint}' + attributeValue='${attributeValue}' + github='${props.githubSourceLink}' + signature='${signature}' + extraSignatures='[${extraSignatures.join(", ")}]' + > + `; +} + +/** + * Removes the original link from sphinx.ext.viewcode and returns the HTML string for our own link. + * + * This returns the HTML string, rather than directly inserting into the HTML, because the insertion + * logic is most easily handled by the calling code. + * + * This function works the same regardless of whether the Sphinx build used `sphinx.ext.viewcode` + * or `sphinx.ext.linkcode` because they have the same HTML structure. + * + * If the link corresponds to a method, we only return a link if it has line numbers included, + * which implies that the link came from `sphinx.ext.linkcode` rather than `sphinx.ext.viewcode`. + * That's because the owning class will already have a link to the relevant file; it's + * noisy and not helpful to repeat the same link without line numbers for the individual methods. + */ +export function prepareGitHubLink( + $child: Cheerio, + isMethod: boolean, +): string | undefined { + const originalLink = $child.find(".viewcode-link").closest("a"); + if (originalLink.length === 0) { + return undefined; + } + const href = originalLink.attr("href")!; + originalLink.first().remove(); + return !isMethod || href.includes(".py#") ? href : undefined; +} + +/** + * Find the element that both matches the `selector` and whose content is the same as `text` + */ +export function findByText( + $: CheerioAPI, + $main: Cheerio, + selector: string, + text: string, +): Cheerio { + return $main.find(selector).filter((i, el) => $(el).text().trim() === text); +} + +export function addExtraSignatures( + componentProps: ComponentProps, + extraRawSignatures: ComponentProps[], +): void { + componentProps.extraRawSignatures = [ + ...extraRawSignatures.flatMap((sigProps) => sigProps.rawSignature ?? []), + ]; +} + +/** + * Converts a given HTML into markdown + */ +export async function htmlSignatureToMd( + signatureHtml: string, +): Promise { + if (!signatureHtml) { + return ""; + } + + const html = `${signatureHtml}`; + const file = await unified() + .use(rehypeParse) + .use(rehypeRemark) + .use(remarkStringify) + .process(html); + + return String(file) + .replaceAll("\n", "") + .replaceAll("'", APOSTROPHE_HEX_CODE) + .replace(/^`/, "") + .replace(/`$/, ""); +} diff --git a/scripts/lib/api/processHtml.test.ts b/scripts/lib/api/processHtml.test.ts index 8a6de44bab0..d73db331d9d 100644 --- a/scripts/lib/api/processHtml.test.ts +++ b/scripts/lib/api/processHtml.test.ts @@ -10,7 +10,6 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. -import { CheerioAPI, Cheerio, load as cheerioLoad } from "cheerio"; import { describe, expect, test } from "@jest/globals"; import { @@ -26,37 +25,14 @@ import { removeMatplotlibFigCaptions, replaceViewcodeLinksWithGitHub, convertRubricsToHeaders, - prepareGitHubLink, processMembersAndSetMeta, } from "./processHtml"; import { Metadata } from "./Metadata"; - -class Doc { - readonly $: CheerioAPI; - readonly $main: Cheerio; - - constructor($: CheerioAPI, $main: Cheerio) { - this.$ = $; - this.$main = $main; - } - - static load(html: string): Doc { - const $ = cheerioLoad(`
${html}
`); - return new Doc($, $("[role='main']")); - } - - html(): string { - return this.$main.html()!.trim(); - } - - expectHtml(expected: string): void { - expect(this.html()).toEqual(expected.trim()); - } -} +import { CheerioDoc } from "../testUtils"; describe("loadImages()", () => { test("normal file", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( `Logo`, ); const images = loadImages(doc.$, doc.$main, "/my-images", false); @@ -76,7 +52,7 @@ describe("loadImages()", () => { }); test("release note", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( ``, ); const images = loadImages(doc.$, doc.$main, "/my-images/0.45", true); @@ -91,7 +67,7 @@ describe("loadImages()", () => { }); test("handleSphinxDesignCards()", () => { - const doc = Doc.load(` + const doc = CheerioDoc.load(`
Account initialization @@ -143,13 +119,13 @@ test("handleSphinxDesignCards()", () => { }); test("renameAllH1s()", () => { - const doc = Doc.load(`

Release Notes!!!

0.45.0

`); + const doc = CheerioDoc.load(`

Release Notes!!!

0.45.0

`); renameAllH1s(doc.$, "New Title"); doc.expectHtml(`

New Title

0.45.0

`); }); test("removeHtmlExtensionsInRelativeLinks()", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( ``, ); removeHtmlExtensionsInRelativeLinks(doc.$, doc.$main); @@ -159,7 +135,7 @@ test("removeHtmlExtensionsInRelativeLinks()", () => { }); test("removeDownloadSourceCode()", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( `

(Source code)

../_images/converters-1.png @@ -174,7 +150,7 @@ test("removeDownloadSourceCode()", () => { }); test("removePermalinks()", () => { - const doc = Doc.load(`Link + const doc = CheerioDoc.load(`Link Link Link Link @@ -185,7 +161,7 @@ test("removePermalinks()", () => { }); test("removeColonSpans()", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( `
Parameters:
`, ); removeColonSpans(doc.$main); @@ -194,7 +170,7 @@ test("removeColonSpans()", () => { describe("removeMatplotlibFigCaptions()", () => { test("removes
", () => { - const doc = Doc.load(` + const doc = CheerioDoc.load(`
../_images/fake_provider-1_00.png
@@ -230,7 +206,7 @@ describe("removeMatplotlibFigCaptions()", () => { }); test("removes
", () => { - const doc = Doc.load(` + const doc = CheerioDoc.load(`
../_images/qiskit-transpiler-passes-DynamicalDecoupling-1_00.png

@@ -276,14 +252,14 @@ describe("removeMatplotlibFigCaptions()", () => {

My caption

`; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); removeMatplotlibFigCaptions(doc.$main); doc.expectHtml(html); }); }); test("addLanguageClassToCodeBlocks()", () => { - const doc1 = Doc.load(`

Circuit symbol:

+ const doc1 = CheerioDoc.load(`

Circuit symbol:

     ┌──────────┐
     q_0: ┤ U(ϴ,φ,λ) ├
         └──────────┘
@@ -301,7 +277,7 @@ test("addLanguageClassToCodeBlocks()", () => {
     
`); - const doc2 = Doc.load(`
+ const doc2 = CheerioDoc.load(`
from qiskit_ibm_runtime.options import Options
 
@@ -324,7 +300,7 @@ test("addLanguageClassToCodeBlocks()", () => {
 
 test("replaceSourceLinksWithGitHub()", () => {
   // Assumes that removeHtmlExtensionsInRelativeLinks() has already removed .html from the URL.
-  const doc = Doc.load(
+  const doc = CheerioDoc.load(
     `
     
     `,
@@ -343,7 +319,7 @@ test("replaceSourceLinksWithGitHub()", () => {
 });
 
 test("convertRubricsToHeaders()", () => {
-  const doc = Doc.load(`

Example

+ const doc = CheerioDoc.load(`

Example

Examples

References

Reference

@@ -366,7 +342,7 @@ describe("maybeSetModuleMetadata()", () => { test("not a module", () => { const html = `

Hello

`; const meta: Metadata = {}; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); maybeSetModuleMetadata(doc.$, doc.$main, meta); doc.expectHtml(html); expect(meta).toEqual({}); @@ -374,7 +350,7 @@ describe("maybeSetModuleMetadata()", () => { const checkModuleFound = (html: string, name: string): void => { const meta: Metadata = {}; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); maybeSetModuleMetadata(doc.$, doc.$main, meta); doc.expectHtml(html); expect(meta).toEqual({ @@ -406,62 +382,6 @@ describe("maybeSetModuleMetadata()", () => { }); }); -describe("prepareGitHubLink()", () => { - test("no link", () => { - const html = `None)#`; - const doc = Doc.load(html); - const result = prepareGitHubLink(doc.$main, false); - expect(result).toEqual(""); - doc.expectHtml(html); - }); - - test("link from sphinx.ext.viewcode", () => { - const doc = Doc.load( - `None)[source]#`, - ); - const result = prepareGitHubLink(doc.$main, false); - expect(result).toEqual( - ` GitHub`, - ); - doc.expectHtml( - `None)#`, - ); - }); - - test("link from sphinx.ext.linkcode", () => { - const doc = Doc.load( - `None)[source]#`, - ); - const result = prepareGitHubLink(doc.$main, false); - expect(result).toEqual( - ` GitHub`, - ); - doc.expectHtml( - `None)#`, - ); - }); - - test("method link only used when it has line numbers", () => { - const withLinesDoc = Doc.load( - `)[source]`, - ); - const withoutLinesDoc = Doc.load( - `)[source]`, - ); - const withLinesResult = prepareGitHubLink(withLinesDoc.$main, true); - const withoutLinesResult = prepareGitHubLink(withoutLinesDoc.$main, true); - - expect(withLinesResult).toEqual( - ` GitHub`, - ); - expect(withoutLinesResult).toEqual(""); - - const strippedHtml = `)`; - withLinesDoc.expectHtml(strippedHtml); - withoutLinesDoc.expectHtml(strippedHtml); - }); -}); - describe("processMembersAndSetMeta()", () => { test("function with added heading", () => { const html = `

Circuit Converters

@@ -476,7 +396,7 @@ describe("processMembersAndSetMeta()", () => {
  • copy_operations – Deep copy the operation objects in the QuantumCircuit for the output DAGCircuit.

  • `; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); const meta: Metadata = {}; processMembersAndSetMeta(doc.$, doc.$main, meta); doc.expectHtml(`

    Circuit Converters

    @@ -523,7 +443,7 @@ backends may not have this attribute.

    `; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); const meta: Metadata = {}; processMembersAndSetMeta(doc.$, doc.$main, meta); doc.expectHtml(`

    least_busy

    @@ -583,7 +503,7 @@ minimal install. You can read more about those, and ways to check for their pre particular error, which subclasses both QiskitError and the Python built-in ImportError.

    `; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); const meta: Metadata = {}; processMembersAndSetMeta(doc.$, doc.$main, meta); doc.expectHtml(`
    diff --git a/scripts/lib/api/processHtml.ts b/scripts/lib/api/processHtml.ts index 543b2f61f16..c5e41c7e36f 100644 --- a/scripts/lib/api/processHtml.ts +++ b/scripts/lib/api/processHtml.ts @@ -15,6 +15,7 @@ import { CheerioAPI, Cheerio, load } from "cheerio"; import { Image } from "./HtmlToMdResult"; import { Metadata, ApiType } from "./Metadata"; import { getLastPartFromFullIdentifier } from "../stringUtils"; +import { findByText, prepareGitHubLink } from "./generateApiComponents"; export type ProcessedHtml = { html: string; @@ -314,7 +315,10 @@ function processMember( apiType: string, id: string, ) { - const githubSourceLink = prepareGitHubLink($child, apiType === "method"); + const githubUrl = prepareGitHubLink($child, apiType === "method"); + const githubSourceLink = githubUrl + ? ` GitHub` + : ""; findByText($, $main, "em.property", apiType).remove(); @@ -448,35 +452,6 @@ function processFunctionOrException( return `

    ${apiName}

    ${descriptionHtml}`; } -/** - * Removes the original link from sphinx.ext.viewcode and returns the HTML string for our own link. - * - * This returns the HTML string, rather than directly inserting into the HTML, because the insertion - * logic is most easily handled by the calling code. - * - * This function works the same regardless of whether the Sphinx build used `sphinx.ext.viewcode` - * or `sphinx.ext.linkcode` because they have the same HTML structure. - * - * If the link corresponds to a method, we only return a link if it has line numbers included, - * which implies that the link came from `sphinx.ext.linkcode` rather than `sphinx.ext.viewcode`. - * That's because the owning class will already have a link to the relevant file; it's - * noisy and not helpful to repeat the same link without line numbers for the individual methods. - */ -export function prepareGitHubLink( - $child: Cheerio, - isMethod: boolean, -): string { - const originalLink = $child.find(".viewcode-link").closest("a"); - if (originalLink.length === 0) { - return ""; - } - const href = originalLink.attr("href")!; - originalLink.first().remove(); - return !isMethod || href.includes(".py#") - ? ` GitHub` - : ""; -} - export function maybeSetModuleMetadata( $: CheerioAPI, $main: Cheerio, @@ -531,18 +506,6 @@ export function updateModuleHeadings( }); } -/** - * Find the element that both matches the `selector` and whose content is the same as `text` - */ -function findByText( - $: CheerioAPI, - $main: Cheerio, - selector: string, - text: string, -): Cheerio { - return $main.find(selector).filter((i, el) => $(el).text().trim() === text); -} - function getApiType($dl: Cheerio): ApiType | undefined { for (const className of [ "function", diff --git a/scripts/lib/stringUtils.test.ts b/scripts/lib/stringUtils.test.ts index 3b42d11cef3..15a3cd71c87 100644 --- a/scripts/lib/stringUtils.test.ts +++ b/scripts/lib/stringUtils.test.ts @@ -17,6 +17,7 @@ import { removePrefix, removeSuffix, getLastPartFromFullIdentifier, + capitalize, } from "./stringUtils"; test("removePart()", () => { @@ -49,3 +50,8 @@ test("getLastPartFromFullIdentifier", () => { expect(getLastPartFromFullIdentifier("my_software")).toEqual("my_software"); expect(getLastPartFromFullIdentifier("")).toEqual(""); }); + +test("capitalize()", () => { + const input = "hello world!"; + expect(capitalize(input)).toEqual("Hello world!"); +}); diff --git a/scripts/lib/stringUtils.ts b/scripts/lib/stringUtils.ts index 531c1eb196d..d30e9562ed9 100644 --- a/scripts/lib/stringUtils.ts +++ b/scripts/lib/stringUtils.ts @@ -12,6 +12,8 @@ import { last, split } from "lodash"; +export const APOSTROPHE_HEX_CODE = "'"; + export function removePart(text: string, separator: string, matcher: string[]) { return text .split(separator) @@ -36,3 +38,7 @@ export function removeSuffix(text: string, suffix: string) { export function getLastPartFromFullIdentifier(fullIdentifierName: string) { return last(split(fullIdentifierName, "."))!; } + +export function capitalize(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/scripts/lib/testUtils.ts b/scripts/lib/testUtils.ts new file mode 100644 index 00000000000..d3a8469fcb0 --- /dev/null +++ b/scripts/lib/testUtils.ts @@ -0,0 +1,36 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2024. +// +// This code is licensed under the Apache License, Version 2.0. You may +// obtain a copy of this license in the LICENSE file in the root directory +// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +// +// Any modifications or derivative works of this code must retain this +// copyright notice, and modified files need to carry a notice indicating +// that they have been altered from the originals. + +import { CheerioAPI, Cheerio, load as cheerioLoad } from "cheerio"; + +export class CheerioDoc { + readonly $: CheerioAPI; + readonly $main: Cheerio; + + constructor($: CheerioAPI, $main: Cheerio) { + this.$ = $; + this.$main = $main; + } + + static load(html: string): CheerioDoc { + const $ = cheerioLoad(`
    ${html}
    `); + return new CheerioDoc($, $("[role='main']")); + } + + html(): string { + return this.$main.html()!.trim(); + } + + expectHtml(expected: string): void { + expect(this.html()).toEqual(expected.trim()); + } +}