From 67748b29d045356ec24ca935903459c33f322f24 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Mon, 26 Oct 2026 19:23:39 +0800 Subject: [PATCH 1/2] fix: restore viewer rendering with EJS 7 --- esbuild.js | 1 + package.json | 3 +- scripts/viewer-rendering.test.js | 135 +++++++++++++++++++++++++++++++ src/helpViewer/helpPreviewer.ts | 2 +- src/plotViewer/httpgdViewer.ts | 2 +- 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 scripts/viewer-rendering.test.js diff --git a/esbuild.js b/esbuild.js index 41cfad62f..cd1fae321 100644 --- a/esbuild.js +++ b/esbuild.js @@ -76,6 +76,7 @@ async function main() { outfile: 'dist/extension.js', external: ['vscode', 'utf-8-validate', 'bufferutil'], logLevel: 'info', + logOverride: { 'import-is-undefined': 'error' }, }); // A session agent remains alive after the extension host exits. diff --git a/package.json b/package.json index e89747d0b..3a63f1bb7 100644 --- a/package.json +++ b/package.json @@ -2601,7 +2601,8 @@ "clean": "node -e \"require('node:fs').rmSync('out', { recursive: true, force: true })\"", "pretest": "pnpm run compile && pnpm run clean && tsc -p ./", "test": "pnpm run test:node && pnpm run test:extension", - "test:node": "mocha \"out/test/node/**/*.test.js\" --config .mocharc.interactive.cjs", + "test:node": "pnpm run test:bundled-viewers && mocha \"out/test/node/**/*.test.js\" --config .mocharc.interactive.cjs", + "test:bundled-viewers": "node --test scripts/viewer-rendering.test.js", "test:extension": "vscode-test", "lint": "oxlint src", "format": "oxfmt .", diff --git a/scripts/viewer-rendering.test.js b/scripts/viewer-rendering.test.js new file mode 100644 index 000000000..3ccd0b10d --- /dev/null +++ b/scripts/viewer-rendering.test.js @@ -0,0 +1,135 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { isBuiltin } = require('node:module'); +const path = require('node:path'); +const { before, describe, test } = require('node:test'); +const { runInNewContext } = require('node:vm'); +const esbuild = require('esbuild'); + +const root = path.resolve(__dirname, '..'); +const plotRoot = path.join(root, 'src/plotViewer/webview'); +const plot = { id: '1', host: 'localhost:1234', data: 'Test plot' }; + +async function loadViewer(source, minify) { + // Keep EJS bundled so its import condition matches the published extension. + // Isolate unrelated extension services without starting VS Code or R. + const services = { + vscode: { Uri: { file: (fsPath) => ({ fsPath }) } }, + '../util': { makeWebviewCommandUriString: (command) => `command:${command}` }, + '../extension': {}, + './httpgdClient': {}, + './packages': {}, + }; + const result = await esbuild.build({ + absWorkingDir: root, + entryPoints: [source], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node22', + minify, + external: Object.keys(services), + write: false, + logLevel: 'silent', + }); + const module = { exports: {} }; + runInNewContext(result.outputFiles[0].text, { + module, + require: (name) => { + if (isBuiltin(name)) { + return require(name); + } + assert.ok(Object.hasOwn(services, name), `Unexpected service: ${name}`); + return services[name]; + }, + }); + return module.exports; +} + +function makePlotViewer(HttpgdViewer) { + // Use the real rendering methods without opening a device connection. + return Object.assign(Object.create(HttpgdViewer.prototype), { + host: plot.host, + htmlRoot: plotRoot, + htmlTemplate: fs.readFileSync(path.join(plotRoot, 'index.ejs'), 'utf8'), + smallPlotTemplate: fs.readFileSync(path.join(plotRoot, 'smallPlot.ejs'), 'utf8'), + plots: [plot], + activePlot: plot.id, + stripStyles: false, + previewPlotLayout: 'multirow', + webviewPanel: { + webview: { + asWebviewUri: (uri) => ({ + toString: () => `https://webview.example/${path.basename(uri.fsPath)}`, + }), + }, + }, + }); +} + +for (const minify of [false, true]) { + describe(`Bundled viewer rendering (${minify ? 'production' : 'development'})`, () => { + let HttpgdViewer; + let RLocalHelpPreviewer; + + before(async () => { + [{ HttpgdViewer }, { RLocalHelpPreviewer }] = await Promise.all([ + loadViewer('src/plotViewer/httpgdViewer.ts', minify), + loadViewer('src/helpViewer/helpPreviewer.ts', minify), + ]); + }); + + test('renders the main plot and included thumbnail', () => { + const viewer = makePlotViewer(HttpgdViewer); + const html = viewer.makeHtml(); + assert.equal(html.split(plot.data).length - 1, 2); + assert.ok(html.includes('command:r.plot.showIndex')); + assert.ok(html.includes('https://webview.example/index.js')); + + viewer.plots = []; + viewer.activePlot = undefined; + const emptyHtml = viewer.makeHtml(); + assert.ok(emptyHtml.includes('id="largePlot"')); + assert.ok(!emptyHtml.includes(plot.data)); + }); + + test('renders an added plot thumbnail in the webview message', () => { + const viewer = makePlotViewer(HttpgdViewer); + const messages = []; + viewer.postWebviewMessage = (message) => messages.push(message); + viewer.focusPlot = async () => {}; + viewer.setContextValues = async () => {}; + + viewer.addPlot(plot); + + assert.equal(messages.length, 1); + assert.equal(messages[0].message, 'addPlot'); + assert.ok(messages[0].html.includes(plot.data)); + assert.ok(messages[0].html.includes('command:r.plot.hidePlot')); + }); + + test('renders a local package index with escaped titles and topic links', async () => { + const previewer = Object.assign(Object.create(RLocalHelpPreviewer.prototype), { + indexTemplate: fs.readFileSync( + path.join(root, 'src/helpViewer/webview/00Index.ejs'), + 'utf8', + ), + getPackageInfo: () => ({ name: 'example', title: 'A & B', version: '1.0.0' }), + getAliases: () => [ + { name: 'example', alias: 'example', title: 'Topic ' }, + ], + }); + + const help = await previewer.getHelpFileFromRequestPath( + '/library/example/html/00Index.html', + ); + + assert.equal(help.isIndex, true); + assert.equal(help.isPreview, true); + assert.ok(help.html.includes('A & B')); + assert.ok(help.html.includes('Topic <title>')); + assert.ok(help.html.includes('href="example.html"')); + assert.ok(help.html.includes('version 1.0.0')); + }); + }); +} diff --git a/src/helpViewer/helpPreviewer.ts b/src/helpViewer/helpPreviewer.ts index 462f1f990..c8d53cd69 100644 --- a/src/helpViewer/helpPreviewer.ts +++ b/src/helpViewer/helpPreviewer.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as vscode from 'vscode'; import * as rHelp from './index'; -import * as ejs from 'ejs'; +import ejs from 'ejs'; import { isDirSafe, isFileSafe, readFileSyncSafe, config, spawnAsync } from '../util'; import { Topic, TopicType } from './packages'; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index 2e28d4f32..257ac279d 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -3,7 +3,7 @@ import { HttpgdClient, HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from ' import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; -import * as ejs from 'ejs'; +import ejs from 'ejs'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; From da213c8e87c26d9fd9ed892e7ba087687f80b54f Mon Sep 17 00:00:00 2001 From: Kun Ren <renkun@outlook.com> Date: Mon, 26 Oct 2026 19:48:54 +0800 Subject: [PATCH 2/2] build: fail non-watch builds on all esbuild warnings --- esbuild.js | 25 +++--- package.json | 3 +- scripts/viewer-rendering.test.js | 135 ------------------------------- 3 files changed, 11 insertions(+), 152 deletions(-) delete mode 100644 scripts/viewer-rendering.test.js diff --git a/esbuild.js b/esbuild.js index cd1fae321..990278cf4 100644 --- a/esbuild.js +++ b/esbuild.js @@ -76,7 +76,6 @@ async function main() { outfile: 'dist/extension.js', external: ['vscode', 'utf-8-validate', 'bufferutil'], logLevel: 'info', - logOverride: { 'import-is-undefined': 'error' }, }); // A session agent remains alive after the extension host exits. @@ -117,23 +116,19 @@ async function main() { logLevel: 'info', }); + const contexts = [extensionCtx, agentCtx, interactiveRendererCtx, webviewCtx]; if (watch) { - await Promise.all([ - extensionCtx.watch(), - agentCtx.watch(), - interactiveRendererCtx.watch(), - webviewCtx.watch() - ]); + await Promise.all(contexts.map(context => context.watch())); console.log('Watching for changes...'); } else { - await extensionCtx.rebuild(); - await agentCtx.rebuild(); - await interactiveRendererCtx.rebuild(); - await webviewCtx.rebuild(); - await extensionCtx.dispose(); - await agentCtx.dispose(); - await interactiveRendererCtx.dispose(); - await webviewCtx.dispose(); + try { + const results = await Promise.all(contexts.map(context => context.rebuild())); + if (results.some(result => result.warnings.length > 0)) { + throw new Error('Build failed because esbuild reported warnings.'); + } + } finally { + await Promise.all(contexts.map(context => context.dispose())); + } } } diff --git a/package.json b/package.json index 3a63f1bb7..e89747d0b 100644 --- a/package.json +++ b/package.json @@ -2601,8 +2601,7 @@ "clean": "node -e \"require('node:fs').rmSync('out', { recursive: true, force: true })\"", "pretest": "pnpm run compile && pnpm run clean && tsc -p ./", "test": "pnpm run test:node && pnpm run test:extension", - "test:node": "pnpm run test:bundled-viewers && mocha \"out/test/node/**/*.test.js\" --config .mocharc.interactive.cjs", - "test:bundled-viewers": "node --test scripts/viewer-rendering.test.js", + "test:node": "mocha \"out/test/node/**/*.test.js\" --config .mocharc.interactive.cjs", "test:extension": "vscode-test", "lint": "oxlint src", "format": "oxfmt .", diff --git a/scripts/viewer-rendering.test.js b/scripts/viewer-rendering.test.js deleted file mode 100644 index 3ccd0b10d..000000000 --- a/scripts/viewer-rendering.test.js +++ /dev/null @@ -1,135 +0,0 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const { isBuiltin } = require('node:module'); -const path = require('node:path'); -const { before, describe, test } = require('node:test'); -const { runInNewContext } = require('node:vm'); -const esbuild = require('esbuild'); - -const root = path.resolve(__dirname, '..'); -const plotRoot = path.join(root, 'src/plotViewer/webview'); -const plot = { id: '1', host: 'localhost:1234', data: '<svg><text>Test plot</text></svg>' }; - -async function loadViewer(source, minify) { - // Keep EJS bundled so its import condition matches the published extension. - // Isolate unrelated extension services without starting VS Code or R. - const services = { - vscode: { Uri: { file: (fsPath) => ({ fsPath }) } }, - '../util': { makeWebviewCommandUriString: (command) => `command:${command}` }, - '../extension': {}, - './httpgdClient': {}, - './packages': {}, - }; - const result = await esbuild.build({ - absWorkingDir: root, - entryPoints: [source], - bundle: true, - format: 'cjs', - platform: 'node', - target: 'node22', - minify, - external: Object.keys(services), - write: false, - logLevel: 'silent', - }); - const module = { exports: {} }; - runInNewContext(result.outputFiles[0].text, { - module, - require: (name) => { - if (isBuiltin(name)) { - return require(name); - } - assert.ok(Object.hasOwn(services, name), `Unexpected service: ${name}`); - return services[name]; - }, - }); - return module.exports; -} - -function makePlotViewer(HttpgdViewer) { - // Use the real rendering methods without opening a device connection. - return Object.assign(Object.create(HttpgdViewer.prototype), { - host: plot.host, - htmlRoot: plotRoot, - htmlTemplate: fs.readFileSync(path.join(plotRoot, 'index.ejs'), 'utf8'), - smallPlotTemplate: fs.readFileSync(path.join(plotRoot, 'smallPlot.ejs'), 'utf8'), - plots: [plot], - activePlot: plot.id, - stripStyles: false, - previewPlotLayout: 'multirow', - webviewPanel: { - webview: { - asWebviewUri: (uri) => ({ - toString: () => `https://webview.example/${path.basename(uri.fsPath)}`, - }), - }, - }, - }); -} - -for (const minify of [false, true]) { - describe(`Bundled viewer rendering (${minify ? 'production' : 'development'})`, () => { - let HttpgdViewer; - let RLocalHelpPreviewer; - - before(async () => { - [{ HttpgdViewer }, { RLocalHelpPreviewer }] = await Promise.all([ - loadViewer('src/plotViewer/httpgdViewer.ts', minify), - loadViewer('src/helpViewer/helpPreviewer.ts', minify), - ]); - }); - - test('renders the main plot and included thumbnail', () => { - const viewer = makePlotViewer(HttpgdViewer); - const html = viewer.makeHtml(); - assert.equal(html.split(plot.data).length - 1, 2); - assert.ok(html.includes('command:r.plot.showIndex')); - assert.ok(html.includes('https://webview.example/index.js')); - - viewer.plots = []; - viewer.activePlot = undefined; - const emptyHtml = viewer.makeHtml(); - assert.ok(emptyHtml.includes('id="largePlot"')); - assert.ok(!emptyHtml.includes(plot.data)); - }); - - test('renders an added plot thumbnail in the webview message', () => { - const viewer = makePlotViewer(HttpgdViewer); - const messages = []; - viewer.postWebviewMessage = (message) => messages.push(message); - viewer.focusPlot = async () => {}; - viewer.setContextValues = async () => {}; - - viewer.addPlot(plot); - - assert.equal(messages.length, 1); - assert.equal(messages[0].message, 'addPlot'); - assert.ok(messages[0].html.includes(plot.data)); - assert.ok(messages[0].html.includes('command:r.plot.hidePlot')); - }); - - test('renders a local package index with escaped titles and topic links', async () => { - const previewer = Object.assign(Object.create(RLocalHelpPreviewer.prototype), { - indexTemplate: fs.readFileSync( - path.join(root, 'src/helpViewer/webview/00Index.ejs'), - 'utf8', - ), - getPackageInfo: () => ({ name: 'example', title: 'A & B', version: '1.0.0' }), - getAliases: () => [ - { name: 'example', alias: 'example', title: 'Topic <title>' }, - ], - }); - - const help = await previewer.getHelpFileFromRequestPath( - '/library/example/html/00Index.html', - ); - - assert.equal(help.isIndex, true); - assert.equal(help.isPreview, true); - assert.ok(help.html.includes('A & B')); - assert.ok(help.html.includes('Topic <title>')); - assert.ok(help.html.includes('href="example.html"')); - assert.ok(help.html.includes('version 1.0.0')); - }); - }); -}