fix(tool-web): bound conversion depth and complete fetch output
Two review findings on the turndown swap, both verified empirically: - Unclosed-tag nesting makes the synchronous turndown/domino walk superlinear (measured: depth 512 ~0.15s, 2k ~2s, 20k ~5s), during which the cooperative fetchTimeoutMs timer cannot fire. renderBody now preflights nesting depth with a linear tag scan and passes bodies past 512 levels through raw; the try/catch stays for markup the scan cannot see (comment-hidden tags), simulated in tests via a converter throw. - Markdown escaping can expand converted HTML ~2x (100k underscores render as 200k chars), so provider body caps no longer bounded the model-visible result. formatFetchOutput now caps the complete output (header + body + footer) under new fetchMaxOutputChars config (default 200000 = 2x the local provider's default body cap), reusing the truncation notice. README EN+ZH, config catalog, Agent Note EN+ZH updated; the new web-fetch fixture is migrated to the packed layout master now requires; tool-web coverage stays 100% per-file.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 5fe48ced81a2cd02197cf8cc10a7d6567b17ffca
|
||||
README.zh.md: 34ad08e290166ee6db2cd7b836746541d18aad52
|
||||
README.md: 44cb1ba2a2f4e1fba7e192d8b6645e0447ebf221
|
||||
README.zh.md: 35b390dd5407af16d84ab391dd8351f784c60035
|
||||
|
||||
@@ -26,8 +26,9 @@ The normalized seam results are also the canonical tool values: `WebSearchResult
|
||||
| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). |
|
||||
| `fetchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_fetch`. |
|
||||
| `searchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_search`. |
|
||||
| `fetchMaxOutputChars` | `200000` | Cap on one `web_fetch` output's characters — header, rendered body, and footer together; a cut body gets the truncation notice. |
|
||||
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument.
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument. `fetchMaxOutputChars` bounds the complete rendered output because markdown escaping can expand converted HTML past a provider's body cap (worst case ~2×); the default is 2× the local provider's default 100,000-character body cap, so it never cuts what that bound already admits.
|
||||
|
||||
```yaml
|
||||
- id: tool-web
|
||||
@@ -126,6 +127,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **HTML→markdown conversion falls back to raw HTML on pathological input** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts fetched HTML through a real DOM, but its recursive walk overflows on absurdly deep nesting (thousands of levels); such a body passes through unconverted rather than erroring ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)).
|
||||
- **HTML→markdown conversion falls back to raw HTML on pathological input** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts fetched HTML through a real DOM, but the synchronous walk is superlinear on deep unclosed nesting, so bodies nested past a fixed 512-level preflight bound pass through unconverted (as does anything that still makes turndown throw) rather than stalling the event loop or erroring ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)).
|
||||
- **The model-facing surface is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md).
|
||||
- **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants.
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
| `searchMaxResults` | `8` | 一次 `web_search` 调用返回的源数量上限(seam 截断更长的提供方列表并标记)。 |
|
||||
| `fetchTimeoutMs` | `30000` | `web_fetch` 的协作式工具调用超时预算(ms)。 |
|
||||
| `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 |
|
||||
| `fetchMaxOutputChars` | `200000` | 单次 `web_fetch` 输出的字符上限——状态头、渲染后的主体与页脚合并计算;被截断的主体带截断提示。 |
|
||||
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 对完整渲染输出设上限:markdown 转义可能让转换后的 HTML 超出提供方的主体上限(最坏约 2 倍);默认值取本地提供方默认 100,000 字符主体上限的 2 倍,因此绝不会削减该上限本已允许的内容。
|
||||
|
||||
```yaml
|
||||
- id: tool-web
|
||||
@@ -126,6 +127,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **HTML→markdown 转换在病态输入上回退为原始 HTML**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换抓取到的 HTML,但其递归遍历在极深嵌套(数千层)上会栈溢出;此类主体不经转换原样通过,而非报错([决策记录](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。
|
||||
- **HTML→markdown 转换在病态输入上回退为原始 HTML**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换抓取到的 HTML,但同步遍历在深层未闭合嵌套上呈超线性,因此嵌套超过固定 512 层预检上限的主体不经转换原样通过(仍让 turndown 抛异常的输入同样如此),而非阻塞事件循环或报错([决策记录](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。
|
||||
- **面向模型的表层有意保持最小,提升项暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM 摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。
|
||||
- **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久 URL/domain 授权。
|
||||
|
||||
@@ -44,23 +44,69 @@ export function parseFetchArgs(args: { url: string }): { url: string } {
|
||||
return { url: args.url }
|
||||
}
|
||||
|
||||
/**
|
||||
* Nesting-depth ceiling above which HTML skips conversion and passes through
|
||||
* raw. Conversion runs synchronously on the event loop, and unclosed-tag
|
||||
* nesting makes domino's tree (and turndown's walk over it) superlinear —
|
||||
* measured: depth 512 ≈ 0.15s, 2,000 ≈ 2s, 20,000 ≈ 5s — during which the
|
||||
* cooperative `fetchTimeoutMs` timer cannot fire. Real pages nest a few dozen
|
||||
* levels; 512 is far above content and far below weaponizable. A robustness
|
||||
* invariant, not a tunable.
|
||||
*/
|
||||
const MAX_CONVERSION_DEPTH = 512
|
||||
|
||||
/** Elements that never take a closing tag, so they must not count toward nesting depth. */
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
||||
])
|
||||
|
||||
/**
|
||||
* Estimate the maximum element nesting depth of an HTML string with one linear
|
||||
* tag scan. Overestimates when markup-like text sits inside `script`/`style`
|
||||
* bodies or comments (the scan does not parse those), which can only cause a
|
||||
* spurious raw-HTML fallback, never a missed bound.
|
||||
*
|
||||
* @param html - the decoded HTML body.
|
||||
* @returns the deepest open-element count the scan reaches.
|
||||
*/
|
||||
export function htmlNestingDepth(html: string): number {
|
||||
let depth = 0
|
||||
let max = 0
|
||||
for (const tag of html.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g)) {
|
||||
const [, closing, rawName = '', selfClosing] = tag
|
||||
const name = rawName.toLowerCase()
|
||||
if (VOID_ELEMENTS.has(name) || selfClosing === '/') continue
|
||||
if (closing === '/') {
|
||||
if (depth > 0) depth -= 1
|
||||
} else {
|
||||
depth += 1
|
||||
if (depth > max) max = depth
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a fetched body to model-facing markdown text.
|
||||
*
|
||||
* @param body - the decoded body; `html` is converted via turndown, `text`
|
||||
* passes through verbatim. When turndown throws (deeply pathological HTML
|
||||
* overflows its recursive DOM walk), the raw HTML passes through instead —
|
||||
* a degraded page beats an error for a body the provider already decoded.
|
||||
* passes through verbatim. HTML nested beyond {@link MAX_CONVERSION_DEPTH}
|
||||
* skips conversion up front (the synchronous walk over such trees is
|
||||
* superlinear and blocks the event loop past the cooperative timeout), and
|
||||
* when turndown itself throws the raw HTML passes through instead — a
|
||||
* degraded page beats an error for a body the provider already decoded.
|
||||
* @returns the text for the tool's output block.
|
||||
*/
|
||||
export function renderBody(body: WebFetchBody): string {
|
||||
switch (body.kind) {
|
||||
case 'html':
|
||||
if (htmlNestingDepth(body.content) > MAX_CONVERSION_DEPTH) return body.content
|
||||
try {
|
||||
return turndown.turndown(body.content)
|
||||
} catch {
|
||||
// turndown's DOM walk recurses per element; pathological nesting (a
|
||||
// few thousand levels) throws RangeError. Provider errors stay
|
||||
// turndown's DOM walk recurses per element; malformed markup the depth
|
||||
// scan cannot see can still throw RangeError. Provider errors stay
|
||||
// structured WebErrors upstream; conversion failure downgrades to raw HTML.
|
||||
return body.content
|
||||
}
|
||||
@@ -72,17 +118,28 @@ export function renderBody(body: WebFetchBody): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** The truncation notice appended when the provider or the output cap cut content. */
|
||||
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block.
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
* Markdown escaping can expand converted HTML (worst case ~2× the provider's
|
||||
* body cap), so the bound applies here, where the complete output — header,
|
||||
* rendered body, and footer — is known.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string; a cut body gets
|
||||
* the same fetch-something-narrower notice as provider-side truncation.
|
||||
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
|
||||
* fetch-something-narrower notice when the provider truncated the content.
|
||||
* truncation notice when the provider or the cap cut the content.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult): string {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})`
|
||||
const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : ''
|
||||
return `${header}\n\n${renderBody(result.body)}${footer}`
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`
|
||||
const body = renderBody(result.body)
|
||||
const full = `${header}${body}${result.truncated ? TRUNCATION_FOOTER : ''}`
|
||||
if (full.length <= maxOutputChars) return full
|
||||
const budget = Math.max(0, maxOutputChars - header.length - TRUNCATION_FOOTER.length)
|
||||
return `${header}${body.slice(0, budget)}${TRUNCATION_FOOTER}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,8 +159,11 @@ export function presentFetchCall(args: { url: string }): GenericCallView {
|
||||
* registrations; both are effect-scoped and unregister on plugin dispose.
|
||||
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
|
||||
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
|
||||
* @param maxOutputChars - cap on the complete rendered tool output (see
|
||||
* {@link formatFetchOutput}); markdown escaping can outgrow the provider's
|
||||
* body cap, so the model-context bound is enforced on the rendered result.
|
||||
*/
|
||||
export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
|
||||
export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChars: number): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_fetch',
|
||||
order: 111,
|
||||
@@ -147,7 +207,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
|
||||
truncated: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
|
||||
@@ -13,7 +13,7 @@ import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts'
|
||||
import { applyWebFetchTool } from './fetch.ts'
|
||||
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, htmlNestingDepth, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-web'
|
||||
@@ -24,7 +24,16 @@ export const inject = ['tools', 'web', 'systemPrompt']
|
||||
/** Default cooperative tool-call timeout budget (ms) for the web tools. */
|
||||
export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */
|
||||
/**
|
||||
* Default cap on one `web_fetch` output's characters. Markdown escaping can
|
||||
* roughly double converted HTML, so this sits at 2× the local provider's
|
||||
* default 100,000-char body cap: it never cuts what that composition's
|
||||
* provider bound already admits, while restoring a model-context bound for
|
||||
* providers with larger or absent body caps.
|
||||
*/
|
||||
export const DEFAULT_FETCH_MAX_OUTPUT_CHARS = 200_000
|
||||
|
||||
/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
|
||||
export interface Config {
|
||||
/** Register `web_search`. Defaults to true. */
|
||||
search?: boolean
|
||||
@@ -36,6 +45,8 @@ export interface Config {
|
||||
fetchTimeoutMs?: number
|
||||
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
|
||||
searchTimeoutMs?: number
|
||||
/** Cap on one `web_fetch` output's characters (header, rendered body, and footer). Defaults to 200000. */
|
||||
fetchMaxOutputChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -44,6 +55,7 @@ export const Config: z<Config> = z.object({
|
||||
searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS),
|
||||
fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
|
||||
searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
|
||||
fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applies its defaults to every field. */
|
||||
@@ -71,6 +83,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
|
||||
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
|
||||
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
|
||||
assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars)
|
||||
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
|
||||
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs)
|
||||
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import TurndownService from 'turndown'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
@@ -9,6 +10,7 @@ import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
import {
|
||||
formatSearchOutput,
|
||||
formatFetchOutput,
|
||||
htmlNestingDepth,
|
||||
parseSearchArgs,
|
||||
parseFetchArgs,
|
||||
presentSearchCall,
|
||||
@@ -92,11 +94,13 @@ describe('search formatting', () => {
|
||||
})
|
||||
|
||||
describe('fetch formatting', () => {
|
||||
const NO_CAP = 1_000_000
|
||||
|
||||
it('renders an html body to markdown text with a status header', () => {
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
|
||||
})
|
||||
}, NO_CAP)
|
||||
expect(out).toContain('Fetched https://a.test (HTTP 200)')
|
||||
expect(out).toContain('# Title')
|
||||
expect(out).toContain('Body text')
|
||||
@@ -106,11 +110,37 @@ describe('fetch formatting', () => {
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: true,
|
||||
body: { kind: 'text', content: 'plain' },
|
||||
})
|
||||
}, NO_CAP)
|
||||
expect(out).toContain('plain')
|
||||
expect(out).toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('caps the complete output and notes truncation, even when markdown escaping expands the body', () => {
|
||||
// 1,000 underscores render as 2,000 escaped characters — conversion can
|
||||
// outgrow a provider-side body cap, so the bound applies to the output.
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: `<p>${'_'.repeat(1000)}</p>` },
|
||||
}, 500)
|
||||
expect(out.length).toBeLessThanOrEqual(500)
|
||||
expect(out).toContain('Fetched https://a.test (HTTP 200)')
|
||||
expect(out).toContain('\\_\\_')
|
||||
expect(out).toContain('Content truncated')
|
||||
// Exact and tiny caps: the complete result is bounded, header and footer included.
|
||||
const exact = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'text', content: 'abc' },
|
||||
}, 'Fetched https://a.test (HTTP 200)\n\nabc'.length)
|
||||
expect(exact).toBe('Fetched https://a.test (HTTP 200)\n\nabc')
|
||||
const tiny = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: true,
|
||||
body: { kind: 'text', content: 'abcdef' },
|
||||
}, 10)
|
||||
expect(tiny).toContain('Fetched https://a.test (HTTP 200)')
|
||||
expect(tiny).toContain('Content truncated')
|
||||
expect(tiny).not.toContain('abcdef')
|
||||
})
|
||||
|
||||
it('renderBody dispatches on kind', () => {
|
||||
expect(renderBody({ kind: 'text', content: 'x' })).toBe('x')
|
||||
expect(renderBody({ kind: 'html', content: '<p>y</p>' })).toBe('y')
|
||||
@@ -129,14 +159,38 @@ describe('fetch formatting', () => {
|
||||
.toBe('**bold _italic_**\n\n> quoted')
|
||||
})
|
||||
|
||||
it('falls back to the raw html body when turndown throws on pathological nesting', { timeout: 60_000 }, () => {
|
||||
// Nesting past V8's default stack overflows turndown/domino's recursive
|
||||
// walk with a RangeError (measured: 4k levels throw on the main thread,
|
||||
// 8k in a worker); 20k adds margin over either stack size. The raw body
|
||||
// must pass through instead of throwing.
|
||||
it('passes deeply nested html through raw without attempting conversion', () => {
|
||||
// Unclosed-tag nesting makes the synchronous conversion superlinear
|
||||
// (seconds at 20k levels, during which the cooperative timeout cannot
|
||||
// fire), so the depth preflight skips conversion entirely; this must
|
||||
// return fast, not merely not-throw.
|
||||
const depth = 20_000
|
||||
const pathological = '<div>'.repeat(depth) + 'x' + '</div>'.repeat(depth)
|
||||
const started = Date.now()
|
||||
expect(renderBody({ kind: 'html', content: pathological })).toBe(pathological)
|
||||
expect(Date.now() - started).toBeLessThan(2_000)
|
||||
})
|
||||
|
||||
it('htmlNestingDepth counts open elements, ignoring void and self-closing tags', () => {
|
||||
expect(htmlNestingDepth('<div><p>x</p></div>')).toBe(2)
|
||||
expect(htmlNestingDepth('<div><br><img src="x"><input/></div>')).toBe(1)
|
||||
expect(htmlNestingDepth('</div></div><p>x</p>')).toBe(1)
|
||||
expect(htmlNestingDepth('plain text, no tags')).toBe(0)
|
||||
expect(htmlNestingDepth('<div>'.repeat(600))).toBe(600)
|
||||
})
|
||||
|
||||
it('falls back to the raw html when turndown throws despite a shallow depth scan', () => {
|
||||
// Comments hide markup from the depth scan by design (it may only
|
||||
// over-count, never under-count real elements); simulate the residual
|
||||
// turndown failure path with a converter throw instead.
|
||||
const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockImplementation(() => {
|
||||
throw new RangeError('Maximum call stack size exceeded')
|
||||
})
|
||||
try {
|
||||
expect(renderBody({ kind: 'html', content: '<p>x</p>' })).toBe('<p>x</p>')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('validates url (non-empty), no timeout parameter', () => {
|
||||
|
||||
Reference in New Issue
Block a user