فهرست منبع

Merge pull request #1150 from bferanmi806-sketch/fix/937-jsdom-file-loader

Alvin 6 روز پیش
والد
کامیت
64ac0ba36f
6فایلهای تغییر یافته به همراه169 افزوده شده و 31 حذف شده
  1. 38 0
      scripts/verify-release-artifact.ts
  2. 1 15
      src/index.ts
  3. 7 4
      src/tools/smartfetch/utils.test.ts
  4. 14 12
      src/tools/smartfetch/utils.ts
  5. 55 0
      src/utils/jsdom.test.ts
  6. 54 0
      src/utils/jsdom.ts

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

@@ -200,7 +200,45 @@ function verifyFreshInstall(tarballPath: string) {
       "if (pkg?.id !== 'oh-my-opencode-slim') throw new Error('default export has an unexpected plugin id');",
       "if (typeof pkg.server !== 'function') throw new Error('default export is missing a server plugin factory');",
       "if (typeof pkg.setup !== 'function') throw new Error('default export is missing a v2 setup factory');",
+      'const asyncNoop = async () => ({});',
+      'const client = new Proxy({}, {',
+      '  get(_target, property) {',
+      "    if (property === 'app') return { log: asyncNoop };",
+      "    if (property === 'session') return { abort: asyncNoop };",
+      '    return new Proxy({}, { get: () => asyncNoop });',
+      '  },',
+      '});',
+      'globalThis.fetch = async () => new Response(',
+      "  '<!doctype html><html><head><title>Release smoke</title></head><body><main><h1>Release smoke</h1><p>packaged jsdom extraction works</p></main></body></html>',",
+      "  { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } },",
+      ');',
+      'const plugin = await pkg.server({',
+      '  client,',
+      '  directory: process.cwd(),',
+      '  worktree: process.cwd(),',
+      "  serverUrl: new URL('http://127.0.0.1:4096'),",
+      '});',
+      'const webfetch = plugin?.tool?.webfetch;',
+      "if (typeof webfetch?.execute !== 'function') throw new Error('server plugin did not register webfetch');",
+      'const result = await webfetch.execute({',
+      "  url: 'https://example.com/release-smoke',",
+      "  format: 'markdown',",
+      '  timeout: 10,',
+      '  extract_main: true,',
+      "  prefer_llms_txt: 'never',",
+      '  include_metadata: false,',
+      '  save_binary: false,',
+      '}, {',
+      '  ask: async () => undefined,',
+      '  metadata: () => undefined,',
+      '  abort: new AbortController().signal,',
+      '  directory: process.cwd(),',
+      "  sessionID: 'release-smoke',",
+      '});',
+      "if (!String(result).includes('packaged jsdom extraction works')) throw new Error('packaged webfetch did not extract the expected document');",
+      'await plugin.dispose?.();',
       "console.log('package loads');",
+      "console.log('packaged webfetch constructs and extracts a document');",
       'process.exit(0);',
     ].join('\n');
     console.log('Importing installed package entrypoint...');

+ 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');

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

@@ -0,0 +1,55 @@
+import { describe, expect, test } from 'bun:test';
+import { createJSDOMLoader, 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 jsdom = await loadJSDOM();
+    let loadCount = 0;
+    const load = createJSDOMLoader(async () => {
+      loadCount += 1;
+      await Promise.resolve();
+      return jsdom;
+    });
+
+    const firstPromise = load();
+    const secondPromise = load();
+    const [first, second] = await Promise.all([firstPromise, secondPromise]);
+
+    expect(loadCount).toBe(1);
+    expect(firstPromise).toBe(secondPromise);
+    expect(first.JSDOM).toBe(second.JSDOM);
+    expect(first.VirtualConsole).toBe(second.VirtualConsole);
+  });
+
+  test('does not cache a failed load', async () => {
+    const jsdom = await loadJSDOM();
+    let loadCount = 0;
+    const load = createJSDOMLoader(async () => {
+      loadCount += 1;
+      if (loadCount === 1) throw new Error('transient jsdom failure');
+      return jsdom;
+    });
+
+    await expect(load()).rejects.toThrow('transient jsdom failure');
+    await expect(load()).resolves.toBe(jsdom);
+    expect(loadCount).toBe(2);
+  });
+
+  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');
+  });
+});

+ 54 - 0
src/utils/jsdom.ts

@@ -0,0 +1,54 @@
+import { createRequire } from 'node:module';
+import { pathToFileURL } from 'node:url';
+
+export type JsdomModule = typeof import('jsdom');
+type JsdomLoader = () => Promise<JsdomModule>;
+
+async function importJSDOM(): Promise<JsdomModule> {
+  const require = createRequire(import.meta.url);
+  const entrypoint = require.resolve('jsdom');
+  return (await import(pathToFileURL(entrypoint).href)) as JsdomModule;
+}
+
+export function createJSDOMLoader(loader: JsdomLoader): JsdomLoader {
+  let promise: Promise<JsdomModule> | undefined;
+
+  return () => {
+    if (promise) return promise;
+
+    const pending = loader().catch((error) => {
+      if (promise === pending) promise = undefined;
+      throw error;
+    });
+    promise = pending;
+    return pending;
+  };
+}
+
+const loadJSDOMFromFile = createJSDOMLoader(importJSDOM);
+
+/**
+ * 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> {
+  return loadJSDOMFromFile();
+}
+
+/**
+ * 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);
+  }
+}