Просмотр исходного кода

fix(smartfetch): load jsdom from file-backed module

Balogun Feranmi 6 дней назад
Родитель
Сommit
5ceefdd2ee

+ 13 - 0
scripts/verify-release-artifact.ts

@@ -208,6 +208,19 @@ function verifyFreshInstall(tarballPath: string) {
       cwd: installDir,
     });
 
+    const jsdomSmokeScript = [
+      "import { JSDOM } from 'jsdom';",
+      "const dom = new JSDOM('<p>hello</p>');",
+      "if (dom.window.document.querySelector('p')?.textContent !== 'hello') throw new Error('JSDOM did not construct the expected document');",
+      'dom.window.close();',
+      "console.log('jsdom constructs a document');",
+      'process.exit(0);',
+    ].join('\n');
+    console.log('Importing installed jsdom and constructing a document...');
+    run('node', ['--input-type=module', '--eval', jsdomSmokeScript], {
+      cwd: installDir,
+    });
+
     const tuiSmokeScript = [
       "import pkg from 'oh-my-opencode-slim/tui';",
       "if (pkg?.id !== 'oh-my-opencode-slim:tui') throw new Error('TUI export has an unexpected plugin id');",

+ 1 - 15
src/index.ts

@@ -89,6 +89,7 @@ import {
 } from './utils';
 import type { ContextFile } from './utils/background-job-board';
 import { isPluginDisabledByEnv } from './utils/env';
+import { probeJSDOM } from './utils/jsdom';
 import { initLogger, log } from './utils/logger';
 import { SessionMetadataStore } from './utils/session-metadata';
 import { collapseSystemInPlace } from './utils/system-collapse';
@@ -121,21 +122,6 @@ async function appLog(
 const lastImageSkippedToastByDir = new Map<string, number>();
 const IMAGE_SKIPPED_DEBOUNCE_MS = 60_000;
 
-/**
- * Probe jsdom at init time so the first webfetch call doesn't fail
- * silently. Logs a warning if jsdom can't be imported or instantiated,
- * but does not throw; the plugin works without webfetch.
- */
-async function probeJSDOM(): Promise<string | null> {
-  try {
-    const { JSDOM } = await import('jsdom');
-    new JSDOM('<!DOCTYPE html><html><body>test</body></html>');
-    return null;
-  } catch (err) {
-    return String(err);
-  }
-}
-
 // Module-level runtime preset tracking. Survives plugin re-inits triggered
 // by client.config.update() → Instance.dispose(). When the plugin function
 // re-runs, it checks this variable and applies the runtime preset instead

+ 7 - 4
src/tools/smartfetch/utils.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, test } from 'bun:test';
+import { loadJSDOM } from '../../utils/jsdom';
 import {
   extractFromHtml,
   extractHeadingsFromMarkdown,
@@ -138,17 +139,18 @@ describe('smartfetch/utils', () => {
     }
   });
 
-  test('forwards non-css-parsing jsdomErrors to console.error', () => {
+  test('forwards non-css-parsing jsdomErrors to console.error', async () => {
     const originalError = console.error;
     const errorCalls: unknown[][] = [];
     console.error = (...args: unknown[]) => errorCalls.push(args);
     try {
+      const { VirtualConsole } = await loadJSDOM();
       withJsdomCssParsingErrorsSuppressed((vc) => {
         vc.emit('jsdomError', {
           type: 'resource-loading',
           message: 'Failed to load resource',
         });
-      });
+      }, VirtualConsole);
 
       expect(errorCalls).toHaveLength(1);
       expect((errorCalls[0][0] as Error).message).toBe(
@@ -159,11 +161,12 @@ describe('smartfetch/utils', () => {
     }
   });
 
-  test('filters css-parsing errors but forwards other jsdomErrors', () => {
+  test('filters css-parsing errors but forwards other jsdomErrors', async () => {
     const originalError = console.error;
     const errorCalls: unknown[][] = [];
     console.error = (...args: unknown[]) => errorCalls.push(args);
     try {
+      const { VirtualConsole } = await loadJSDOM();
       withJsdomCssParsingErrorsSuppressed((vc) => {
         vc.emit('jsdomError', {
           type: 'css-parsing',
@@ -173,7 +176,7 @@ describe('smartfetch/utils', () => {
           type: 'resource-loading',
           message: 'Failed to load resource',
         });
-      });
+      }, VirtualConsole);
 
       expect(errorCalls).toHaveLength(1);
       expect((errorCalls[0][0] as Error).message).toBe(

+ 14 - 12
src/tools/smartfetch/utils.ts

@@ -1,8 +1,9 @@
 import { Readability } from '@mozilla/readability';
-import { JSDOM, VirtualConsole } from 'jsdom';
+import type { VirtualConsole } from 'jsdom';
 import TurndownService from 'turndown';
 import { escapeHtml } from '../../utils/escape-html';
 import { parseFrontmatter } from '../../utils/frontmatter';
+import { type JsdomModule, loadJSDOM } from '../../utils/jsdom';
 import type { CachedFetch, ExtractedContent } from './types';
 
 export { escapeHtml, parseFrontmatter };
@@ -37,8 +38,9 @@ export function withCssTreeWarningsSuppressed<T>(fn: () => T): T {
  */
 export function withJsdomCssParsingErrorsSuppressed<T>(
   fn: (vc: VirtualConsole) => T,
+  VirtualConsoleClass: JsdomModule['VirtualConsole'],
 ): T {
-  const vc = new VirtualConsole();
+  const vc = new VirtualConsoleClass();
   vc.on('jsdomError', (error) => {
     const type = (error as Error & { type?: string }).type;
     if (type !== 'css-parsing') console.error(error);
@@ -315,11 +317,15 @@ export async function extractFromHtml(
   finalUrl: string,
   extractMain: boolean,
 ): Promise<ExtractedContent> {
-  const dom = withCssTreeWarningsSuppressed(() =>
-    withJsdomCssParsingErrorsSuppressed(
-      (vc) => new JSDOM(html, { url: finalUrl, virtualConsole: vc }),
-    ),
-  );
+  const { JSDOM, VirtualConsole } = await loadJSDOM();
+  const createDom = () =>
+    withCssTreeWarningsSuppressed(() =>
+      withJsdomCssParsingErrorsSuppressed(
+        (vc) => new JSDOM(html, { url: finalUrl, virtualConsole: vc }),
+        VirtualConsole,
+      ),
+    );
+  const dom = createDom();
   const document = dom.window.document;
   const title = document.title || undefined;
   const canonical =
@@ -341,11 +347,7 @@ export async function extractFromHtml(
     .slice(0, 12);
 
   if (extractMain) {
-    const readerDom = withCssTreeWarningsSuppressed(() =>
-      withJsdomCssParsingErrorsSuppressed(
-        (vc) => new JSDOM(html, { url: finalUrl, virtualConsole: vc }),
-      ),
-    );
+    const readerDom = createDom();
     const article = new Readability(readerDom.window.document).parse();
     if (article?.content?.trim()) {
       const articleContainer = readerDom.window.document.createElement('div');

+ 29 - 0
src/utils/jsdom.test.ts

@@ -0,0 +1,29 @@
+import { describe, expect, test } from 'bun:test';
+import { loadJSDOM, probeJSDOM } from './jsdom';
+
+describe('jsdom loader', () => {
+  test('loads jsdom and constructs a basic document', async () => {
+    const { JSDOM } = await loadJSDOM();
+    const dom = new JSDOM('<p id="message">hello</p>');
+
+    expect(dom.window.document.querySelector('#message')?.textContent).toBe(
+      'hello',
+    );
+    dom.window.close();
+  }, 15_000);
+
+  test('shares the in-flight module load', async () => {
+    const [first, second] = await Promise.all([loadJSDOM(), loadJSDOM()]);
+
+    expect(first.JSDOM).toBe(second.JSDOM);
+    expect(first.VirtualConsole).toBe(second.VirtualConsole);
+  });
+
+  test('reports loader failures without throwing from the probe', async () => {
+    const result = await probeJSDOM(async () => {
+      throw new Error('jsdom unavailable');
+    });
+
+    expect(result).toBe('Error: jsdom unavailable');
+  });
+});

+ 40 - 0
src/utils/jsdom.ts

@@ -0,0 +1,40 @@
+import { createRequire } from 'node:module';
+import { pathToFileURL } from 'node:url';
+
+export type JsdomModule = typeof import('jsdom');
+type JsdomLoader = () => Promise<JsdomModule>;
+
+let jsdomPromise: Promise<JsdomModule> | undefined;
+
+async function importJSDOM(): Promise<JsdomModule> {
+  const require = createRequire(import.meta.url);
+  const entrypoint = require.resolve('jsdom');
+  return (await import(pathToFileURL(entrypoint).href)) as JsdomModule;
+}
+
+/**
+ * Resolve jsdom to its installed file before importing it. This keeps
+ * transitive relative asset loads anchored to a real filesystem URL instead
+ * of the host's bundled module URL.
+ */
+export function loadJSDOM(): Promise<JsdomModule> {
+  jsdomPromise ??= importJSDOM();
+  return jsdomPromise;
+}
+
+/**
+ * Probe jsdom without making it a prerequisite for the rest of the plugin.
+ * The optional loader is a test seam for the non-fatal failure contract.
+ */
+export async function probeJSDOM(
+  load: JsdomLoader = loadJSDOM,
+): Promise<string | null> {
+  try {
+    const { JSDOM } = await load();
+    const dom = new JSDOM('<!DOCTYPE html><html><body>test</body></html>');
+    dom.window.close();
+    return null;
+  } catch (err) {
+    return String(err);
+  }
+}