Public: anyone can read this; the crew can change it.
A Jupyter notebook (.ipynb) renders as its cells and outputs, nothing run #10638
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10283-a-jupyter-notebook-ipynb-renders-as-its → main 3 commits fork 4 threads · 4 unresolved
10 files
changed +271
−3
docs/design/areas/render.md +4
| @@ -18,6 +18,10 @@ The header shows the size (`1.2 MB · PDF`) through `Rendered.summary`. Both the | ||
| 18 | 18 | |
| 19 | 19 | Backgrounds, 256-colour above 15 and truecolour are dropped; so are cursor moves and OSC. A log with no escape code keeps the plain source view. |
| 20 | 20 | |
| 21 | ## Notebook (.ipynb) | |
| 22 | ||
| 23 | `src/web/render/notebook.ts` draws cells as rows inside `.nb.prose`, with space between them and no box. Classes: `.nb-cell` (one row), `.nb-img` (an output picture, `max-width: 100%; height: auto` so a wide plot fits a phone), `.nb-cap` (a plain caption, muted, before HTML or script output shown as text). A long `pre` scrolls inside its cell, never the page. | |
| 24 | ||
| 21 | 25 | ## Archive listing |
| 22 | 26 | |
| 23 | 27 | A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the existing `.table` alone, with no `.card` around it (path, size, modified; `data-dense`), and a `tfoot` for the count and unpacked size. No new class: `.archive-view` is only a hook on the table and has no rule. How it works: `docs/features/archive-listing.md`. A list that cannot be read falls back to the Download card with the reason as its one sentence. |
docs/features/notebook.md added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | # Jupyter notebooks | |
| 2 | ||
| 3 | A `.ipynb` file opens on the blob page as its cells and saved outputs (`src/web/render/notebook.ts`). Nothing is run. | |
| 4 | Markdown cells go through the forge's Markdown renderer. A code cell is a `pre` headed `In [n]:` with its execution count. Output text (streams, plain text, errors with their colour codes removed) is escaped text. PNG and JPEG outputs are pictures, drawn only when the base64 is well-formed, and shrink to fit a phone. HTML and script outputs are shown as escaped text and never inserted or run. Other output kinds are skipped. | |
| 5 | A notebook over 1 MB, one that is not valid JSON, or one with no `cells` list shows the ordinary source view with one line saying why. At most 2,000 cells, and 2,000 outputs per cell, are drawn. `?view=source`, Raw and Download show the bytes untouched. | |
| 6 | The terminal (SSH) file view has no notebook reader: it shows the file as plain text, the JSON itself, escaped like any text file. Nothing in it is run there either. Reading cells in the terminal is left out. Classes live in `docs/design/areas/render.md`. |
src/web/pages/repo.ts +5 −3
| @@ -88,7 +88,7 @@ import { overSize } from "../render/archive.ts"; | ||
| 88 | 88 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| 89 | 89 | import { fontJs } from "../render/font.ts"; |
| 90 | 90 | import { hasTable } from "../render/sqlite.ts"; |
| 91 | import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts"; | |
| 91 | import { fileKind, HEAD_BYTES, kindByName, RENDER_CAP } from "../render/kinds.ts"; | |
| 92 | 92 | import { RENDERERS, rendererFor } from "../render/renderers.ts"; |
| 93 | 93 | import { downloadHref, mediaHref } from "./media.ts"; |
| 94 | 94 | import { browseBase, localScope, type RepoScope } from "../scope.ts"; |
| @@ -722,7 +722,7 @@ export function blobBody( | ||
| 722 | 722 | content: empty({ |
| 723 | 723 | icon: "alert", |
| 724 | 724 | title: "Too large to render", |
| 725 | note: `${fileSize(blob.size)} — over the render cap. Download it instead.`, | |
| 725 | note: why !== undefined ? `${fileSize(blob.size)} — ${why}` : `${fileSize(blob.size)} — over the render cap. Download it instead.`, | |
| 726 | 726 | action: btn("Download", { |
| 727 | 727 | href: downloadHref(base, ref, path), |
| 728 | 728 | variant: "primary", |
| @@ -751,7 +751,7 @@ export function blobBody( | ||
| 751 | 751 | const fileHref = `${base}/blob/${encoded}/${path}`; |
| 752 | 752 | return { |
| 753 | 753 | lines: total, |
| 754 | content: html`<div class="card code"> | |
| 754 | content: html`${why !== undefined ? html`<p class="nb-cap">${why}</p>` : html``}<div class="card code"> | |
| 755 | 755 | <table> |
| 756 | 756 | ${raw(rows)} |
| 757 | 757 | </table> |
| @@ -1097,6 +1097,8 @@ function blobRenderer(scope: RepoScope, path: string, blob: Blob): ReturnType<ty | ||
| 1097 | 1097 | |
| 1098 | 1098 | /** Why an archive too big to list is not listed, as one sentence. */ |
| 1099 | 1099 | function overCap(path: string, blob: Blob): string | undefined { |
| 1100 | if (kindByName(path) === "notebook" && blob.size > RENDER_CAP.notebook) | |
| 1101 | return `This notebook is over ${RENDER_CAP.notebook / 1_000_000} MB, so it is not drawn as cells${blob.kind === "too_large" ? ". Download it to read it" : " and is shown as plain text"}.`; | |
| 1100 | 1102 | if (blob.kind !== "binary" || fileKind(path, blob.body.subarray(0, HEAD_BYTES)) !== "archive") return undefined; |
| 1101 | 1103 | return blob.size > RENDER_CAP.archive ? overSize(RENDER_CAP.archive) : undefined; |
| 1102 | 1104 | } |
src/web/render/kinds.ts +3
| @@ -25,6 +25,7 @@ export type FileKind = | ||
| 25 | 25 | | "font" |
| 26 | 26 | | "config" |
| 27 | 27 | | "markdown" |
| 28 | | "notebook" | |
| 28 | 29 | | "patch" |
| 29 | 30 | | "log" |
| 30 | 31 | | "subtitles" |
| @@ -54,6 +55,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 54 | 55 | font: 20 * MB, |
| 55 | 56 | config: 1 * MB, |
| 56 | 57 | markdown: 1 * MB, |
| 58 | notebook: 1 * MB, | |
| 57 | 59 | patch: 1 * MB, |
| 58 | 60 | log: 1 * MB, |
| 59 | 61 | subtitles: 5 * MB, |
| @@ -159,6 +161,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 159 | 161 | md: "markdown", |
| 160 | 162 | markdown: "markdown", |
| 161 | 163 | mdx: "markdown", |
| 164 | ipynb: "notebook", | |
| 162 | 165 | patch: "patch", |
| 163 | 166 | diff: "patch", |
| 164 | 167 | log: "log", |
src/web/render/notebook.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * A Jupyter notebook as its cells and saved outputs; nothing is run. Markdown | |
| 3 | * cells go through the forge's own Markdown renderer. Everything else is | |
| 4 | * escaped text, except PNG and JPEG outputs, which are `<img>` data URIs | |
| 5 | * only after the base64 is checked. HTML and JavaScript outputs are shown | |
| 6 | * as source and never inserted. | |
| 7 | */ | |
| 8 | ||
| 9 | import { html, type Html } from "../html.ts"; | |
| 10 | import { renderMarkdown } from "../markdown.ts"; | |
| 11 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 12 | ||
| 13 | type Json = Record<string, unknown>; | |
| 14 | const isObj = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v); | |
| 15 | ||
| 16 | /** nbformat stores text as a string or an array of lines. */ | |
| 17 | const text = (v: unknown): string => | |
| 18 | typeof v === "string" ? v : Array.isArray(v) ? v.filter((x) => typeof x === "string").join("") : ""; | |
| 19 | ||
| 20 | const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; | |
| 21 | const IMAGES = ["image/png", "image/jpeg"] as const; | |
| 22 | /** Outputs shown as source, with the plain words that name them. */ | |
| 23 | const SOURCES = [ | |
| 24 | ["text/html", "HTML output, shown as text"], | |
outdated
Unstyled image with no max-width: overflows a 360px phone. Needs |
||
| 25 | ["application/javascript", "Script output, shown as text"], | |
| 26 | ] as const; | |
| 27 | /** The most outputs and cells drawn, so a hostile file cannot make a huge page. */ | |
| 28 | const MAX_CELLS = 2_000; | |
| 29 | ||
| 30 | const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 31 | ||
| 32 | function image(mime: string, data: unknown): Html | undefined { | |
| 33 | const b64 = text(data).replace(/\s+/g, ""); | |
| 34 | if (!BASE64.test(b64)) return undefined; | |
| 35 | return html`<img class="nb-img" alt="Notebook output" src="data:${mime};base64,${b64}" />`; | |
| 36 | } | |
| 37 | ||
| 38 | function output(out: unknown): Html { | |
| 39 | if (!isObj(out)) return html``; | |
| 40 | if (out.output_type === "stream") | |
| 41 | return html`<pre class="nb-out">${text(out.text)}</pre>`; | |
| 42 | if (out.output_type === "error") { | |
| 43 | const trace = Array.isArray(out.traceback) ? text(out.traceback) : `${text(out.ename)}: ${text(out.evalue)}`; | |
| 44 | return html`<pre class="nb-out nb-err">${stripAnsi(trace)}</pre>`; | |
| 45 | } | |
| 46 | const data = isObj(out.data) ? out.data : {}; | |
outdated
Principle 8: raw mime type in a hand-rolled |
||
| 47 | const parts: Html[] = []; | |
| 48 | for (const mime of IMAGES) { | |
| 49 | if (data[mime] === undefined) continue; | |
| 50 | const img = image(mime, data[mime]); | |
| 51 | if (img !== undefined) parts.push(img); | |
| 52 | } | |
| 53 | if (data["text/plain"] !== undefined) parts.push(html`<pre class="nb-out">${text(data["text/plain"])}</pre>`); | |
| 54 | for (const [mime, label] of SOURCES) | |
| 55 | if (data[mime] !== undefined) | |
| 56 | parts.push(html`<div class="nb-cap">${label}</div><pre class="nb-out">${text(data[mime])}</pre>`); | |
| 57 | return html`${parts}`; | |
| 58 | } | |
| 59 | ||
| 60 | function cell(c: unknown): Html { | |
| 61 | if (!isObj(c)) return html``; | |
| 62 | const source = text(c.source); | |
| 63 | if (c.cell_type === "markdown") return html`<div class="nb-cell">${renderMarkdown(source)}</div>`; | |
| 64 | if (c.cell_type === "code") { | |
| 65 | const n = typeof c.execution_count === "number" && Number.isFinite(c.execution_count) ? String(c.execution_count) : " "; | |
| 66 | const outs = Array.isArray(c.outputs) ? c.outputs.slice(0, MAX_CELLS).map(output) : []; | |
| 67 | return html`<div class="nb-cell"><pre class="nb-in"><span class="muted">In [${n}]:</span> ${source}</pre>${outs}</div>`; | |
| 68 | } | |
| 69 | return html`<div class="nb-cell"><pre>${source}</pre></div>`; | |
| 70 | } | |
| 71 | ||
| 72 | export function notebook(input: RenderInput): Rendered { | |
| 73 | const fail = (why: string): Rendered => ({ content: html``, lines: 0, fallback: why }); | |
| 74 | let doc: unknown; | |
| 75 | try { | |
| 76 | doc = JSON.parse(new TextDecoder().decode(input.body)); | |
| 77 | } catch { | |
| 78 | return fail("This notebook is not valid JSON, so it is shown as plain text."); | |
| 79 | } | |
| 80 | if (!isObj(doc) || !Array.isArray(doc.cells)) | |
| 81 | return fail("This notebook has no cells list, so it is shown as plain text."); | |
| 82 | const cells = doc.cells.slice(0, MAX_CELLS); | |
outdated
Principles 6 and 7: a card around the whole notebook is a box around a thing. Use rows with space between cells. |
||
| 83 | return { | |
| 84 | lines: 0, | |
| 85 | summary: `${doc.cells.length} cells`, | |
| 86 | content: html`<div class="nb prose">${cells.map(cell)}</div>`, | |
| 87 | }; | |
| 88 | } |
src/web/render/renderers.ts +2
| @@ -19,6 +19,7 @@ import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } | ||
| 19 | 19 | import { log, hasEscape } from "./log.ts"; |
| 20 | 20 | import { markdown } from "./markdown.ts"; |
| 21 | 21 | import { model3d } from "./model3d.ts"; |
| 22 | import { notebook } from "./notebook.ts"; | |
| 22 | 23 | import { patch } from "./patch.ts"; |
| 23 | 24 | import { pdf } from "./pdf.ts"; |
| 24 | 25 | import { sqlite } from "./sqlite.ts"; |
| @@ -62,6 +63,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 62 | 63 | image, |
| 63 | 64 | svg: image, |
| 64 | 65 | markdown, |
| 66 | notebook, | |
| 65 | 67 | pdf, |
| 66 | 68 | csv, |
| 67 | 69 | sqlite, |
src/web/style/content.ts +6
| @@ -414,6 +414,12 @@ form.inline { display: inline; margin-left: auto; } | ||
| 414 | 414 | .csv-view .num { text-align: right; } |
| 415 | 415 | .csv-more { padding: 10px 12px; margin: 0; } |
| 416 | 416 | |
| 417 | /* A notebook (render/notebook.ts): cells are rows with space between, never boxes. */ | |
| 418 | .nb-cell { margin: 0 0 28px; min-width: 0; } | |
| 419 | .nb pre { max-width: 100%; overflow-x: auto; margin: 8px 0; } | |
| 420 | .nb-img { display: block; max-width: 100%; height: auto; margin: 8px 0; } | |
| 421 | .nb-cap { color: var(--muted-fg); font-size: 12.5px; margin-top: 8px; } | |
| 422 | ||
| 417 | 423 | /* A terminal log (render/log.ts): SGR colours as fixed classes, theme tokens only. */ |
| 418 | 424 | .ansi-red { color: var(--danger); } |
| 419 | 425 | .ansi-green { color: var(--success); } |
test/blob-notebook-page.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * A `.ipynb` committed to a repository, opened on its blob page over HTTP by | |
| 3 | * an anonymous reader: the server-rendered route and its facts as rows. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | describe("a .ipynb on its blob page", () => { | |
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let base = ""; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-blob-ipynb-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | base = servers.url; | |
| 29 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-patch-passphrase" }); | |
| 30 | if (!a.ok) throw new Error("signup failed"); | |
| 31 | const created = await createRepo(app, a.value, { name: "p", visibility: "public" }); | |
| 32 | if (!created.ok) throw new Error("repo failed"); | |
| 33 | const small = JSON.stringify({ | |
| 34 | cells: [{ cell_type: "code", execution_count: 1, source: "1", outputs: [{ output_type: "execute_result", data: { "text/html": "<script>alert(1)</script>" } }] }], | |
| 35 | }); | |
| 36 | const big = JSON.stringify({ cells: [{ cell_type: "markdown", source: "x".repeat(1_100_000) }] }); | |
| 37 | const made = await commitToBranch(app, a.value, created.value, { | |
| 38 | message: "notebooks", | |
| 39 | files: [ | |
| 40 | { path: "ok.ipynb", content: small }, | |
| 41 | { path: "big.ipynb", content: big }, | |
| 42 | { path: "bad.ipynb", content: "{not json" }, | |
| 43 | ], | |
| 44 | }); | |
| 45 | if (!made.ok) throw new Error("commit failed"); | |
| 46 | }, 60_000); | |
| 47 | ||
| 48 | afterAll(async () => { | |
| 49 | await servers?.stop(); | |
| 50 | app?.close(); | |
| 51 | await rm(dataDir, { recursive: true, force: true }); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a notebook is drawn as cells, its script output escaped", async () => { | |
| 55 | const text = await (await fetch(`${base}/alice/p/blob/main/ok.ipynb`)).text(); | |
| 56 | expect(text).toContain("In [1]:"); | |
| 57 | expect(text).toContain("<script>alert(1)</script>"); | |
| 58 | expect(text).not.toContain("<script>alert(1)"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("over 1 MB, the page says why it is not drawn as cells", async () => { | |
| 62 | const res = await fetch(`${base}/alice/p/blob/main/big.ipynb`); | |
| 63 | const text = await res.text(); | |
| 64 | expect(res.status).toBe(200); | |
| 65 | expect(text).toContain("This notebook is over 1 MB, so it is not drawn as cells"); | |
| 66 | expect(text).not.toContain("In ["); | |
| 67 | }); | |
| 68 | ||
| 69 | test("invalid JSON says why on the page", async () => { | |
| 70 | const text = await (await fetch(`${base}/alice/p/blob/main/bad.ipynb`)).text(); | |
| 71 | expect(text).toContain("not valid JSON"); | |
| 72 | }); | |
| 73 | }); |
test/render-kinds.test.ts +1
| @@ -60,6 +60,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 60 | 60 | zip: [of(0x50, 0x4b, 3, 4, 0), "archive", "application/octet-stream"], |
| 61 | 61 | tar: [enc("x".repeat(257) + "ustar\0"), "archive", "application/octet-stream"], |
| 62 | 62 | tgz: [of(0x1f, 0x8b, 8), "archive", "application/octet-stream"], |
| 63 | ipynb: [enc("{}"), "notebook", "application/octet-stream"], | |
| 63 | 64 | md: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 64 | 65 | markdown: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 65 | 66 | mdx: [enc("# a\n"), "markdown", "application/octet-stream"], |
test/render-notebook.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** A notebook as cells and outputs: hostile HTML output stays inert text. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { notebook } from "../src/web/render/notebook.ts"; | |
| 4 | import { fileKind, RENDER_CAP } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (s: string) => notebook({ path: "a.ipynb", size: s.length, body: enc(s), media: "/m", download: "/d" }); | |
| 9 | const PNG = "iVBORw0KGgo="; | |
| 10 | const NB = JSON.stringify({ | |
| 11 | cells: [ | |
| 12 | { cell_type: "markdown", source: ["# Title\n", "some *prose*"] }, | |
| 13 | { | |
| 14 | cell_type: "code", | |
| 15 | execution_count: 3, | |
| 16 | source: "print('<b>x</b>')", | |
| 17 | outputs: [ | |
| 18 | { output_type: "stream", name: "stdout", text: ["a < b\n"] }, | |
| 19 | { output_type: "display_data", data: { "image/png": PNG, "text/plain": "<Figure>" } }, | |
| 20 | { output_type: "execute_result", data: { "text/html": ["<script>alert(1)</script>"], "application/javascript": "alert(2)" } }, | |
| 21 | { output_type: "display_data", data: { "image/png": '"onerror="alert(3)' } }, | |
| 22 | ], | |
| 23 | }, | |
| 24 | ], | |
| 25 | }); | |
| 26 | ||
| 27 | describe("notebooks", () => { | |
| 28 | test("the registry names .ipynb and a renderer draws it", () => { | |
| 29 | expect(fileKind("x/a.ipynb", enc("{}"))).toBe("notebook"); | |
| 30 | expect(RENDERERS.notebook).toBe(notebook); | |
| 31 | }); | |
| 32 | ||
| 33 | test("cells, outputs, and hostile HTML arrives escaped", () => { | |
| 34 | const r = draw(NB); | |
| 35 | const out = String(r.content); | |
| 36 | expect(r.fallback).toBeUndefined(); | |
| 37 | expect(out).toContain("<h1"); | |
| 38 | expect(out).toContain("In [3]:"); | |
| 39 | expect(out).toContain("a < b"); | |
| 40 | expect(out).toContain(`<img class="nb-img" alt="Notebook output" src="data:image/png;base64,${PNG}"`); | |
| 41 | expect(out).toContain("<script>alert(1)</script>"); | |
| 42 | expect(out).not.toContain("<script"); | |
| 43 | expect(out).toContain("<b>x</b>"); | |
| 44 | expect(out).not.toContain("onerror"); | |
| 45 | expect(out.match(/<img/g)?.length).toBe(1); | |
| 46 | }); | |
| 47 | ||
| 48 | test("invalid JSON, or no cells, falls back with a reason", () => { | |
| 49 | expect(draw("{nope").fallback).toContain("not valid JSON"); | |
| 50 | expect(draw("[]").fallback).toContain("no cells"); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a notebook over the cap is not handed to the renderer", () => { | |
| 54 | const big = { kind: "text", size: RENDER_CAP.notebook + 1, body: enc("{}") } as never; | |
| 55 | expect(rendererFor("a.ipynb", big)).toBeUndefined(); | |
| 56 | expect(rendererFor("a.ipynb", { kind: "text", size: 2, body: enc("{}") } as never)?.render).toBe(notebook); | |
| 57 | }); | |
| 58 | }); | |
| 59 | ||
| 60 | describe("notebook edge cases", () => { | |
| 61 | test("JPEG output is a picture and an error loses its colour codes", () => { | |
| 62 | const out = String( | |
| 63 | draw( | |
| 64 | JSON.stringify({ | |
| 65 | cells: [ | |
| 66 | { | |
| 67 | cell_type: "code", | |
| 68 | source: "x", | |
| 69 | outputs: [ | |
| 70 | { output_type: "display_data", data: { "image/jpeg": "/9j/4AAQ\n" } }, | |
| 71 | { output_type: "error", ename: "E", evalue: "v", traceback: ["\x1b[31mBoom\x1b[0m <b>"] }, | |
| 72 | ], | |
| 73 | }, | |
| 74 | ], | |
| 75 | }), | |
| 76 | ).content, | |
| 77 | ); | |
| 78 | expect(out).toContain('src="data:image/jpeg;base64,/9j/4AAQ"'); | |
| 79 | expect(out).toContain("Boom <b>"); | |
| 80 | expect(out).not.toContain("\x1b"); | |
| 81 | }); | |
| 82 | ||
| 83 | }); |
Non-blocking: nb-cap is a notebook class, but it is used here for a generic file-view reason. Share it as a general caption, or name it for this use.