/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
tests/html-to-markdown-browser.test.mjs
264 строки
12 KB
Maksim Ratnikov
fix: таблица «поле/значение» с вложенными таблицами переживает html to markdown
04 авг 2026, 14:14
04 авг 2026, 14:14
0a06bef
Код
Авторство
О чём код?
import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtempSync, readFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { transformModule } from "../tools/strip-module.mjs"; const execFileAsync = promisify(execFile); const emailLikeHtml = `<!doctype html> <html> <head> <style>.page-content { font-family: Arial; color: red; }</style> <script>window.__leak = true;</script> </head> <body> <table role="presentation" class="page-content"> <tr> <td> <h1>Цели ЦА 2026 по автоматизированным системам</h1> <table> <tr> <td>АС / CI</td> <td>Архитектор</td> <td>Описание API</td> <td>HealthCheck API</td> <td>Прикладной доступ к API / П3963</td> </tr> <tr> <td><strong>AI HUB OPS</strong> CI07012973</td> <td>Грацианов В.Н.</td> <td>Требует подтверждения</td> <td>Требует подтверждения</td> <td><span>Входит по файлу</span><span>Волна 2</span></td> </tr> </table> <h2>Цель 2: HealthCheck API</h2> <p><a href="https://sberchat.sberbank.ru/@apim">Чат API Management</a></p> <h2>Общий onepager: контроль и дашборды</h2> <p>Дашборды по целям:</p> <table role="presentation"> <tr> <td> <a href="https://oko-qs.sigma.sbrf.ru/prom/sense/app/fff5df0f-8e26-4131-90e1-e03327c72994/sheet/6461b2d4-bd7c-4b31-91e8-316c02c6df56/state/analysis">Дашборд описания API →</a> <a href="https://oko-qs.sigma.sbrf.ru/prom/sense/app/9f91f4cd-b667-4eab-9b61-5fe7cd7eab54/sheet/862d20b4-33be-43b0-a242-5b21f3e5406d/state/analysis">Дашборд периметровых API с URL →</a> </td> </tr> </table> </td> </tr> </table> </body> </html>`; const semanticHtml = ` <article> <h1>Title</h1> <p><strong>Bold</strong> <em>Italic</em> <del>Gone</del> <s>Old</s> <kbd>Ctrl+C</kbd> H<sub>2</sub> x<sup>2</sup> <abbr title="HyperText Markup Language">HTML</abbr> <time datetime="2026-07-23"></time> <a href="https://example.com">Link</a> <img src="pic.png" alt="Pic"></p> <p><u>Under</u> <ins>Inserted</ins> <q>Quoted</q> <cite>Citation</cite> <var>x</var> <samp>sample</samp></p> <blockquote><p>Quote</p></blockquote> <ol start="3"><li>Three</li><li>Four</li></ol> <ol reversed><li>Two</li><li>One</li></ol> <ul><li><input type="checkbox" checked>Done</li><li><input type="checkbox">Todo</li></ul> <menu><li>Action</li></menu> <dl><dt>Term</dt><dd>Definition</dd></dl> <details><summary>More</summary><p>Details body</p></details> <figure><img src="chart.png" alt="Chart"><figcaption>Chart caption</figcaption></figure> <table><caption>Stats</caption><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>A</td><td>1</td></tr></tbody></table> <pre><code>const x = 1;</code></pre> <audio src="sound.mp3" title="Sample audio"></audio> <video><source src="movie.mp4"></video> <iframe src="frame.html" title="Frame"></iframe> <p><mark>Marked</mark> <small>Small</small></p> <div hidden><p>Hidden leak</p></div> <script>bad()</script><style>.bad{}</style> </article>`; const propertySheetHtml = ` <section data-type="table"> <table> <tbody> <tr> <th><span><h2>Поле</h2></span></th> <th><span><h2>Значение</h2></span></th> </tr> <tr> <th><span><p>Название метрики</p></span></th> <td><p>Доля внедренных историй и багов.</p></td> </tr> <tr> <th><span><p>Рекомендации</p></span></th> <td> <ul><li>Создайте ветку в Bitbucket.</li><li>Укажите ID Story/Bug.</li></ul> </td> </tr> <tr> <th><span><p>Детальное описание</p></span></th> <td> <p>Расчет показателя:</p> <section data-type="table"> <table> <tbody> <tr><th>Разрез</th><th>Показатель</th></tr> <tr><td>Банк</td><td>числитель*100/знаменатель</td></tr> <tr><td>story_linked_to_code</td><td>true</td></tr> </tbody> </table> </section> </td> </tr> <tr> <th><span><p>Единица измерения</p></span></th> <td><p>%</p></td> </tr> </tbody> </table> </section>`; const formatSource = transformModule(readFileSync(new URL("../src/util/format.js", import.meta.url), "utf8")); const htmlToMarkdownSource = transformModule(readFileSync(new URL("../src/converters/html-to-markdown.js", import.meta.url), "utf8")); const tempDir = mkdtempSync(join(tmpdir(), "html-to-md-")); const safeEmailLikeHtml = JSON.stringify(emailLikeHtml).replace(/<\/script/gi, "<\\/script"); const safeSemanticHtml = JSON.stringify(semanticHtml).replace(/<\/script/gi, "<\\/script"); const safePropertySheetHtml = JSON.stringify(propertySheetHtml).replace(/<\/script/gi, "<\\/script"); const harnessHtml = `<!doctype html> <meta charset="utf-8"> <pre id="result"></pre> <script> ${formatSource} ${htmlToMarkdownSource} const input = ${safeEmailLikeHtml}; const markdown = htmlToMarkdown(input); const semanticMarkdown = htmlToMarkdown(${safeSemanticHtml}); const propertySheetMarkdown = htmlToMarkdown(${safePropertySheetHtml}); document.getElementById("result").textContent = encodeURIComponent(JSON.stringify({ markdown, semanticMarkdown, propertySheetMarkdown, lineCount: markdown.split(/\\r?\\n/).length, hasCssLeak: markdown.includes(".page-content") || markdown.includes("font-family: Arial"), hasSystemMatrixHeader: markdown.includes("| АС / CI | Архитектор | Описание API | HealthCheck API | Прикладной доступ к API / П3963 |\\n| --- | --- | --- | --- | --- |"), hasGoalSection: markdown.includes("Цель 2: HealthCheck API"), hasContactLink: markdown.includes("[Чат API Management](https://sberchat.sberbank.ru/@apim)"), hasApiDashboardLink: markdown.includes("[Дашборд описания API →](https://oko-qs.sigma.sbrf.ru/prom/sense/app/fff5df0f-8e26-4131-90e1-e03327c72994/sheet/6461b2d4-bd7c-4b31-91e8-316c02c6df56/state/analysis)"), hasPerimeterDashboardLink: markdown.includes("[Дашборд периметровых API с URL →](https://oko-qs.sigma.sbrf.ru/prom/sense/app/9f91f4cd-b667-4eab-9b61-5fe7cd7eab54/sheet/862d20b4-33be-43b0-a242-5b21f3e5406d/state/analysis)"), hasGenericColumnHeaders: markdown.includes("| Column 1 | Column 2 |"), hasJoinedBadges: markdown.includes("Входит по файлуВолна") })); </script> `; const server = createServer((_request, response) => { response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); response.end(harnessHtml); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); let output; try { const address = server.address(); const chromeBinary = process.env.CHROME_BIN || "google-chrome"; const result = await execFileAsync(chromeBinary, [ "--headless=new", "--disable-gpu", "--no-sandbox", "--disable-crash-reporter", "--disable-breakpad", `--user-data-dir=${join(tempDir, "chrome-profile")}`, "--dump-dom", `http://127.0.0.1:${address.port}/harness.html` ], { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); output = result.stdout; } finally { await new Promise((resolve) => server.close(resolve)); } const resultText = output.match(/<pre id="result">([\s\S]*?)<\/pre>/)?.[1]; assert.ok(resultText, "browser harness produced JSON result"); const result = JSON.parse(decodeURIComponent(resultText)); if (process.env.DEBUG_HTML_TO_MD) { console.log(result.markdown); console.log("\n--- semantic fixture ---\n"); console.log(result.semanticMarkdown); } assert.equal(result.hasCssLeak, false, "email CSS/head content must not leak into Markdown"); assert.equal(result.hasSystemMatrixHeader, true, "АС matrix should use its first row as the Markdown table header"); assert.equal(result.hasGenericColumnHeaders, false, "email tables should not use generic Column N headers when the first row is a header row"); assert.equal(result.hasJoinedBadges, false, "adjacent status badges should not be joined into one word"); assert.equal(result.hasGoalSection, true, "goal sections should remain visible"); assert.equal(result.hasContactLink, true, "links from email content should remain Markdown links"); assert.equal(result.hasApiDashboardLink, true, "dashboard buttons should keep their href in Markdown links"); assert.equal(result.hasPerimeterDashboardLink, true, "multiple dashboard buttons inside one layout cell should keep their hrefs"); assert.ok(result.lineCount > 8, `email-like Markdown should be structured across many lines, got ${result.lineCount}`); const semanticExpectations = [ "# Title", "**Bold** _Italic_ ~~Gone~~ ~~Old~~ `Ctrl+C` H<sub>2</sub> x<sup>2</sup> HTML (HyperText Markup Language) 2026-07-23", "[Link](https://example.com)", "", "<u>Under</u> <ins>Inserted</ins> \"Quoted\" _Citation_ _x_ `sample`", "> Quote", "3. Three\n4. Four", "2. Two\n1. One", "- [x] Done\n- [ ] Todo", "- Action", "- **Term:** Definition", "**More**\n\nDetails body", "\n\n_Chart caption_", "_Stats_\n\n| Name | Value |\n| --- | --- |\n| A | 1 |", "```\nconst x = 1;\n```", "[Sample audio](sound.mp3)", "[Video](movie.mp4)", "[Frame](frame.html)", "Marked Small" ]; for (const expected of semanticExpectations) { assert.ok(result.semanticMarkdown.includes(expected), `semantic HTML should preserve ${JSON.stringify(expected)}`); } for (const forbidden of ["Hidden leak", "bad()", ".bad{}"]) { assert.equal(result.semanticMarkdown.includes(forbidden), false, `semantic HTML should omit ${JSON.stringify(forbidden)}`); } if (process.env.DEBUG_HTML_TO_MD) { console.log("\n--- property sheet fixture ---\n"); console.log(result.propertySheetMarkdown); } const propertySheetExpectations = [ "## Название метрики\n\nДоля внедренных историй и багов.", "## Рекомендации\n\n- Создайте ветку в Bitbucket.\n- Укажите ID Story/Bug.", "## Детальное описание\n\nРасчет показателя:\n\n| Разрез | Показатель |\n| --- | --- |\n| Банк | числитель\\*100/знаменатель |\n| story\\_linked\\_to\\_code | true |", "## Единица измерения\n\n%" ]; for (const expected of propertySheetExpectations) { assert.ok( result.propertySheetMarkdown.includes(expected), `two-column property table should render as sections: ${JSON.stringify(expected)}\n--- got ---\n${result.propertySheetMarkdown}` ); } assert.equal( /^##\s+(Поле|Значение)\s*$/m.test(result.propertySheetMarkdown), false, "the Поле/Значение column-header row should not become a section of its own" ); for (const doubled of ["\\\\_", "\\\\*"]) { assert.equal( result.propertySheetMarkdown.includes(doubled), false, `table cells should not double-escape markdown punctuation (${JSON.stringify(doubled)})` ); }