Browse Source

fix(smartfetch): suppress css-tree lexer warnings corrupting the TUI

mygo 2 weeks ago
parent
commit
2cc32b6537
3 changed files with 100 additions and 3 deletions
  1. 1 0
      src/tools/smartfetch/codemap.md
  2. 72 1
      src/tools/smartfetch/utils.test.ts
  3. 27 2
      src/tools/smartfetch/utils.ts

+ 1 - 0
src/tools/smartfetch/codemap.md

@@ -11,6 +11,7 @@
 - **Transport/policy split from rendering:** `network.ts` focuses on URL normalization, redirect allowlists, charset/body decoding, header extraction, and llms.txt probing, while `utils.ts` focuses on turning fetched content into cleaned text/markdown/html plus frontmatter and user-facing messages.
 - **Cache keyed by fetch shape:** `cache.ts` keys fetches by URL plus behavior-affecting options (`extract_main`, `prefer_llms_txt`, `save_binary`), while render format is derived from the cached fetch result so text/markdown/html do not force redundant network requests.
 - **Graceful degradation:** missing/invalid `llms.txt`, blocked redirects, metadata-only binary responses, and secondary-model failures all return a usable result instead of throwing away the fetched content.
+- **Warning-scoped JSDOM construction:** any new JSDOM construction or css-tree trigger point must be wrapped in `withCssTreeWarningsSuppressed` (see `utils.ts`) so css-tree lexer warnings never leak into the host process stderr.
 
 ## Data & Control Flow
 

+ 72 - 1
src/tools/smartfetch/utils.test.ts

@@ -1,5 +1,23 @@
 import { describe, expect, test } from 'bun:test';
-import { extractHeadingsFromMarkdown, joinRenderedContent } from './utils';
+import {
+  extractFromHtml,
+  extractHeadingsFromMarkdown,
+  joinRenderedContent,
+  withCssTreeWarningsSuppressed,
+} from './utils';
+
+// 200 段逗号分隔的 box-shadow 链 —— csstree/csstree#294 的复现案例,
+// 稳定触发 css-tree lexer 15000 迭代上限警告(jsdom 29 + css-tree 3.2.1 已验证)。
+// 若上游修复后不再触发,本测试退化为弱断言(无泄漏仍成立),可移除 helper。
+const CSS_TREE_WARNING_HTML = (() => {
+  const shadows: string[] = [];
+  for (let i = 1; i <= 200; i++) {
+    shadows.push(`${i}px 0 0 -${Math.min(i + 3, 200)}px #cfcfcf`);
+  }
+  return `<!DOCTYPE html><html><head><style>
+.range-block__range::-webkit-slider-thumb { box-shadow: ${shadows.join(', ')}; }
+</style></head><body><article><h1>Hello</h1><p>World</p></article></body></html>`;
+})();
 
 describe('smartfetch/utils', () => {
   test('extracts cleaned headings from markdown', () => {
@@ -21,4 +39,57 @@ describe('smartfetch/utils', () => {
     expect(result).toContain('<!--\n---\nsource: "smartfetch"\n---\n-->');
     expect(result).toContain('<root>ok</root>');
   });
+
+  test('suppresses css-tree warnings during html extraction', async () => {
+    const originalWarn = console.warn;
+    const warnCalls: unknown[][] = [];
+    console.warn = (...args: unknown[]) => warnCalls.push(args);
+    try {
+      const result = await extractFromHtml(
+        CSS_TREE_WARNING_HTML,
+        'https://example.com/',
+        false,
+      );
+
+      const cssTreeWarnings = warnCalls.filter((args) =>
+        String(args[0]).startsWith('[csstree-match]'),
+      );
+      expect(cssTreeWarnings).toEqual([]);
+      expect(result.text).toContain('Hello');
+      expect(result.text).toContain('World');
+    } finally {
+      console.warn = originalWarn;
+    }
+  });
+
+  test('filters only css-tree warnings inside the guard', () => {
+    const originalWarn = console.warn;
+    const warnCalls: unknown[][] = [];
+    console.warn = (...args: unknown[]) => warnCalls.push(args);
+    try {
+      withCssTreeWarningsSuppressed(() => {
+        console.warn('[csstree-match] BREAK after 15000 iterations');
+        console.warn('[smartfetch] unrelated warning');
+      });
+
+      expect(warnCalls).toEqual([['[smartfetch] unrelated warning']]);
+    } finally {
+      console.warn = originalWarn;
+    }
+  });
+
+  test('restores the original console.warn after extraction', async () => {
+    const originalWarn = console.warn;
+    try {
+      await extractFromHtml(
+        CSS_TREE_WARNING_HTML,
+        'https://example.com/',
+        true,
+      );
+
+      expect(console.warn).toBe(originalWarn);
+    } finally {
+      console.warn = originalWarn;
+    }
+  });
 });

+ 27 - 2
src/tools/smartfetch/utils.ts

@@ -6,6 +6,27 @@ import type { CachedFetch, ExtractedContent } from './types';
 
 export { escapeHtml, parseFrontmatter };
 
+const CSS_TREE_WARN_PREFIX = '[csstree-match]';
+
+/**
+ * Suppresses css-tree lexer warnings ([csstree-match] prefix) emitted
+ * synchronously during JSDOM construction (jsdom uses css-tree to parse
+ * stylesheets; css-tree calls the global console.warn directly, bypassing
+ * jsdom's virtualConsole). Other warnings pass through untouched.
+ */
+export function withCssTreeWarningsSuppressed<T>(fn: () => T): T {
+  const originalWarn = console.warn;
+  console.warn = ((...args: unknown[]) => {
+    const first = typeof args[0] === 'string' ? args[0] : '';
+    if (!first.startsWith(CSS_TREE_WARN_PREFIX)) originalWarn(...args);
+  }) as typeof console.warn;
+  try {
+    return fn();
+  } finally {
+    console.warn = originalWarn;
+  }
+}
+
 let jsdomPromise: Promise<typeof import('jsdom')> | undefined;
 
 async function getJSDOM() {
@@ -284,7 +305,9 @@ export async function extractFromHtml(
   extractMain: boolean,
 ): Promise<ExtractedContent> {
   const JSDOM = await getJSDOM();
-  const dom = new JSDOM(html, { url: finalUrl });
+  const dom = withCssTreeWarningsSuppressed(
+    () => new JSDOM(html, { url: finalUrl }),
+  );
   const document = dom.window.document;
   const title = document.title || undefined;
   const canonical =
@@ -306,7 +329,9 @@ export async function extractFromHtml(
     .slice(0, 12);
 
   if (extractMain) {
-    const readerDom = new JSDOM(html, { url: finalUrl });
+    const readerDom = withCssTreeWarningsSuppressed(
+      () => new JSDOM(html, { url: finalUrl }),
+    );
     const article = new Readability(readerDom.window.document).parse();
     if (article?.content?.trim()) {
       const articleContainer = readerDom.window.document.createElement('div');