diff --git a/jest.config.js b/jest.config.js index ca43b1b8..2aa2d206 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,7 @@ module.exports = { '^@/(.*)$': '/workspace-mcp-server/src/$1', '\\.wasm$': '/workspace-mcp-server/src/__tests__/mocks/wasm.js', '^marked$': '/workspace-mcp-server/src/__tests__/mocks/marked.js', + '^jsdom$': '/workspace-mcp-server/src/__tests__/mocks/jsdom.ts', }, setupFilesAfterEnv: ['/workspace-mcp-server/src/__tests__/setup.ts'], collectCoverageFrom: [ diff --git a/package-lock.json b/package-lock.json index 111c6380..c45ba172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "dompurify": "^3.1.6", "google-auth-library": "^10.4.0", "googleapis": "^166.0.0", - "jsdom": "^27.0.0", + "jsdom": "^27.2.0", "keytar": "^7.9.0", "marked": "^16.2.1" }, @@ -31,7 +31,7 @@ "@jest/globals": "^30.0.5", "@types/dompurify": "^3.0.5", "@types/jest": "^30.0.0", - "@types/jsdom": "^21.1.7", + "@types/jsdom": "^27.0.0", "@types/node": "^24.2.1", "@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/parser": "^8.46.4", @@ -48,6 +48,12 @@ "typescript": "^5.9.2" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.23", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.23.tgz", + "integrity": "sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", @@ -71,9 +77,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.0.tgz", - "integrity": "sha512-GrYRsKf8oVnPHsA+4dOAnPybrhT3cQ0xykXxjj2DaOni5xOlV1T8/Nqo+iNUO7wh9bs3jViIFsxJKFzDTU/ulQ==", + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", @@ -730,9 +736,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", - "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.16.tgz", + "integrity": "sha512-2SpS4/UaWQaGpBINyG5ZuCHnUDeVByOhvbkARwfmnfxDvTaj80yOI1cD8Tw93ICV5Fx4fnyDKWQZI1CDtcWyUg==", "funding": [ { "type": "github", @@ -746,9 +752,6 @@ "license": "MIT-0", "engines": { "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" } }, "node_modules/@csstools/css-tokenizer": { @@ -2562,9 +2565,9 @@ } }, "node_modules/@types/jsdom": { - "version": "21.1.7", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", - "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", "dev": true, "license": "MIT", "dependencies": { @@ -4311,9 +4314,9 @@ } }, "node_modules/cssstyle": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", - "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", + "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^4.0.3", @@ -7640,21 +7643,21 @@ } }, "node_modules/jsdom": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", - "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", + "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "license": "MIT", "dependencies": { - "@asamuzakjp/dom-selector": "^6.5.4", - "cssstyle": "^5.3.0", + "@acemir/cssom": "^0.9.23", + "@asamuzakjp/dom-selector": "^6.7.4", + "cssstyle": "^5.3.3", "data-urls": "^6.0.0", - "decimal.js": "^10.5.0", + "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^7.3.0", - "rrweb-cssom": "^0.8.0", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", @@ -7662,12 +7665,12 @@ "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0", - "ws": "^8.18.2", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -7678,6 +7681,18 @@ } } }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8127,25 +8142,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -8572,6 +8568,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -8663,6 +8660,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -8776,35 +8774,6 @@ "node": ">= 0.4" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -9284,12 +9253,6 @@ "node": ">= 18" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index f1c3fc2f..ac13866d 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "dompurify": "^3.1.6", "google-auth-library": "^10.4.0", "googleapis": "^166.0.0", - "jsdom": "^27.0.0", + "jsdom": "^27.2.0", "keytar": "^7.9.0", "marked": "^16.2.1" }, @@ -40,7 +40,7 @@ "@jest/globals": "^30.0.5", "@types/dompurify": "^3.0.5", "@types/jest": "^30.0.0", - "@types/jsdom": "^21.1.7", + "@types/jsdom": "^27.0.0", "@types/node": "^24.2.1", "@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/parser": "^8.46.4", @@ -58,7 +58,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/google-gemini/gemini-cli-workspace.git" + "url": "git+https://github.com/gemini-cli-extensions/workspace.git" }, "keywords": [ "mcp", diff --git a/workspace-mcp-server/src/__tests__/mocks/jsdom.ts b/workspace-mcp-server/src/__tests__/mocks/jsdom.ts new file mode 100644 index 00000000..40c5d74f --- /dev/null +++ b/workspace-mcp-server/src/__tests__/mocks/jsdom.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Mock implementation of jsdom for Jest tests + * + * This mock provides the minimal DOM functionality needed by our code: + * 1. DocsService uses JSDOM to create a window object for DOMPurify + * 2. markdownToDocsRequests uses JSDOM to parse HTML from marked + */ + +class MockElement { + tagName: string; + nodeType: number; + childNodes: MockNode[]; + nextSibling: MockNode | null; + attributes: { [key: string]: string }; + + constructor(tagName: string) { + this.tagName = tagName; + this.nodeType = 1; // Element node + this.childNodes = []; + this.nextSibling = null; + this.attributes = {}; + } + + get textContent(): string { + return this.childNodes.map(child => child.textContent).join(''); + } + + + toLowerCase() { + return this.tagName.toLowerCase(); + } + + getAttribute(name: string): string | null { + return this.attributes[name] || null; + } +} + +class MockTextNode { + nodeType: number; + textContent: string; + childNodes: never[]; + nextSibling: MockNode | null; + + constructor(text: string) { + this.nodeType = 3; // Text node + this.textContent = text; + this.childNodes = []; + this.nextSibling = null; + } +} + +type MockNode = MockElement | MockTextNode; + +class MockDocument { + body: MockElement; + + constructor() { + this.body = new MockElement('BODY'); + } + + createElement(tagName: string): MockElement { + return new MockElement(tagName.toUpperCase()); + } + + querySelector(selector: string): MockElement | null { + // Simple implementation for our use case (mostly just tag names) + const tagName = selector.toUpperCase(); + + const queue: MockNode[] = [...this.body.childNodes]; + while (queue.length > 0) { + const node = queue.shift()!; + if (node instanceof MockElement) { + if (node.tagName === tagName) { + return node; + } + queue.push(...node.childNodes); + } + } + + return null; + } +} + +class MockWindow { + document: MockDocument; + DOMParser: typeof MockDOMParser; + + constructor() { + this.document = new MockDocument(); + this.DOMParser = MockDOMParser; + } +} + +class MockDOMParser { + parseFromString(html: string): { body: MockElement } { + const body = new MockElement('BODY'); + this.parseNodes(html, body); + return { body }; + } + + private parseNodes(html: string, parent: MockElement) { + // Parse simple HTML tags and text + // Note: This regex is very simple and won't handle attributes or self-closing tags well + // but it's sufficient for the markdown output we're testing + const tagRegex = /<(\w+)(?:\s+[^>]*)?>(.*?)<\/\1>|([^<]+)/gs; + let match; + + while ((match = tagRegex.exec(html)) !== null) { + if (match[1]) { + // It's a tag + const tagName = match[1].toUpperCase(); + const element = new MockElement(tagName); + const content = match[2]; + + // Handle attributes (simple href for links) + const fullTag = match[0]; + const hrefMatch = fullTag.match(/href=["']([^"']*)["']/); + if (hrefMatch) { + element.attributes['href'] = hrefMatch[1]; + } + + // Recursively parse content + this.parseNodes(content, element); + + parent.childNodes.push(element); + } else if (match[3]) { + // It's text content + const text = match[3]; + if (text) { + const textNode = new MockTextNode(text); + parent.childNodes.push(textNode); + } + } + } + + // Set next sibling references + for (let i = 0; i < parent.childNodes.length - 1; i++) { + parent.childNodes[i].nextSibling = parent.childNodes[i + 1]; + } + } +} + +export class JSDOM { + window: MockWindow; + + constructor(html?: string) { + this.window = new MockWindow(); + + if (html) { + const parser = new MockDOMParser(); + const parsed = parser.parseFromString(html); + this.window.document.body = parsed.body; + } + } +} diff --git a/workspace-mcp-server/src/__tests__/services/DocsService.test.ts b/workspace-mcp-server/src/__tests__/services/DocsService.test.ts index a7eb886d..3e80eab5 100644 --- a/workspace-mcp-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-mcp-server/src/__tests__/services/DocsService.test.ts @@ -13,6 +13,11 @@ import { google } from 'googleapis'; // Mock the googleapis module jest.mock('googleapis'); jest.mock('../../utils/logger'); +jest.mock('dompurify', () => { + return jest.fn().mockImplementation(() => ({ + sanitize: jest.fn((content) => content), + })); +}); describe('DocsService', () => { let docsService: DocsService;