diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..72448d3
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,56 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+ inputs:
+ node-red-ref:
+ description: "node-red branch, tag or sha to compare node-red-types and the editor fixture with"
+ required: false
+ default: "master"
+
+permissions:
+ contents: read
+
+jobs:
+ unit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npm test
+
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npm run test:build
+ - run: npx playwright install --with-deps chromium
+ - run: npm run test:browser
+
+ node-red-sync:
+ # Depends on node-red's state, not just this repo, so it only runs on request.
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npm run test:node-red-sync
+ env:
+ NODE_RED_REF: ${{ inputs.node-red-ref }}
diff --git a/README.md b/README.md
index 986809c..1644e5a 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,8 @@ Check + update node-red (function node/server-side) type defs
* `./node-red-types/func.d.ts`
* `./node-red-types/util.d.ts`
+Make type changes here first, or alongside the node-red PR that needs them. `npm run copy` puts them into node-red. If someone changed node-red's copies directly (`editor-client/src/types/node-red/`), bring that change back here before building, or the next copy undoes it. `npm run test:node-red-sync` shows any difference (see [Compare with node-red](#compare-with-node-red)).
+
### Step 3
Install dependencies, clean and build
@@ -55,7 +57,37 @@ This will bundle the monaco editor with localization support and themes:
cd output/monaco/dist/
```
-### Check it works
+### Check the build before copying it to node-red
+
+The output is vendored into node-red as is, so check it first. A build can succeed and still give node-red types that resolve to `any`, a worker that never loads or a locale that translates nothing, with nothing in the build log.
+
+```bash
+npm test # the types, as node-red's Function node editor loads them (no build needed)
+npm run test:build # clean + build, then everything again against output/, plus bundle, locale and theme checks
+npm run test:browser # the built editor in headless Chromium (run `npx playwright install chromium` once)
+```
+
+`npm test` loads the types the way node-red's `monaco.js` does: one `.d.ts` per module, from the list in `test/fixtures/editor-modules.js`. It checks:
+
+* default globals like `Buffer.from()`, `NodeJS.TypedArray`, `node.send()` and `RED.util` resolve to real types, not `any`
+* every module in node-red's `knownModules` resolves when added on the Setup tab
+* the exclude list in `update-types.js` still matches real files, and nothing it excludes ships
+* types that already fail to resolve, and the list of shipped files, match `test/baselines/`
+
+If a bump changes which files node-red has to load (for example @types/node 24 moved `Buffer.from()` into `buffer.buffer.d.ts`), the tests fail on whatever turned into `any`. Update `knownModules`/`defaultServerSideTypes` in node-red's `monaco.js` in a node-red PR alongside this one, and make the same change in `test/fixtures/editor-modules.js`.
+
+When a bump changes a baseline on purpose, run `npm run test:update-baseline` and review the diff before committing it.
+
+#### Compare with node-red
+
+```bash
+NODE_RED_DIR=../node-red npm run test:node-red-sync # a local checkout, on whatever branch it is on
+NODE_RED_REF=dev npm run test:node-red-sync # or a branch/tag on GitHub (default master)
+```
+
+Compares `node-red-types/` with node-red's copies, to catch changes made directly in node-red that need bringing back here. Also compares `test/fixtures/editor-modules.js` with the `monaco.js` this build is going into. Differences are expected while the matching node-red PR is still open.
+
+#### Check it by eye
Check editor works in browser...
diff --git a/demo.html b/demo.html
index 8b4fe20..c1792d0 100644
--- a/demo.html
+++ b/demo.html
@@ -168,7 +168,9 @@
Things to check...
types + "node-red/util.d.ts",
types + "node-red/func.d.ts",
types + "node/globals.d.ts",
+ types + "node/globals.typedarray.d.ts",
types + "node/buffer.d.ts",
+ types + "node/buffer.buffer.d.ts",
types + "node/console.d.ts",
types + "node/fs.d.ts",
types + "node/fs/promises.d.ts",
diff --git a/package-lock.json b/package-lock.json
index a2ff730..74ccc7e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"monaco-editor-webpack-plugin": "^7.1.1",
"monaco-themes": "^0.4.7",
"ncp": "^2.0.0",
+ "playwright": "^1.64.0",
"read-package-json": "^7.0.1",
"recursive-readdir": "^2.2.3",
"replace-in-file": "^6.3.2",
@@ -1988,6 +1989,35 @@
"node": ">=8"
}
},
+ "node_modules/playwright": {
+ "version": "1.64.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
+ "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.64.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.64.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
+ "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
diff --git a/package.json b/package.json
index c4cb61d..b2cef70 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,11 @@
"pack-dev": "npx webpack --mode development",
"demo": "npx http-server -p 8080 -c-1 -o demo.html",
"generate-extraLibs": "node generate-extraLibs.js",
+ "test": "node test/run.js",
+ "test:update-baseline": "node test/run.js --update-baseline",
+ "test:build": "npm run clean && npm run build && node test/run.js --output",
+ "test:node-red-sync": "node test/run.js --node-red-sync",
+ "test:browser": "node test/run.js --browser",
"copyCheck1": "test -d output/monaco/dist",
"copyCheck2": "test -d ../node-red/packages/node_modules/@node-red/editor-client/src",
"copyPrep1": "rm -rf ../node-red/packages/node_modules/@node-red/editor-client/src/vendor/monaco/dist/*",
@@ -44,6 +49,7 @@
"monaco-editor-webpack-plugin": "^7.1.1",
"monaco-themes": "^0.4.7",
"ncp": "^2.0.0",
+ "playwright": "^1.64.0",
"read-package-json": "^7.0.1",
"recursive-readdir": "^2.2.3",
"replace-in-file": "^6.3.2",
diff --git a/test/baselines/shipped-files.json b/test/baselines/shipped-files.json
new file mode 100644
index 0000000..4946404
--- /dev/null
+++ b/test/baselines/shipped-files.json
@@ -0,0 +1,72 @@
+{
+ "node": [
+ "assert.d.ts",
+ "assert/strict.d.ts",
+ "async_hooks.d.ts",
+ "buffer.buffer.d.ts",
+ "buffer.d.ts",
+ "child_process.d.ts",
+ "cluster.d.ts",
+ "compatibility/iterators.d.ts",
+ "console.d.ts",
+ "crypto.d.ts",
+ "dgram.d.ts",
+ "diagnostics_channel.d.ts",
+ "dns.d.ts",
+ "dns/promises.d.ts",
+ "domain.d.ts",
+ "events.d.ts",
+ "fs.d.ts",
+ "fs/promises.d.ts",
+ "globals.d.ts",
+ "globals.typedarray.d.ts",
+ "http.d.ts",
+ "http2.d.ts",
+ "https.d.ts",
+ "module.d.ts",
+ "net.d.ts",
+ "os.d.ts",
+ "path.d.ts",
+ "perf_hooks.d.ts",
+ "process.d.ts",
+ "querystring.d.ts",
+ "readline.d.ts",
+ "readline/promises.d.ts",
+ "sea.d.ts",
+ "sqlite.d.ts",
+ "stream.d.ts",
+ "stream/consumers.d.ts",
+ "stream/promises.d.ts",
+ "stream/web.d.ts",
+ "string_decoder.d.ts",
+ "test.d.ts",
+ "timers.d.ts",
+ "timers/promises.d.ts",
+ "tls.d.ts",
+ "trace_events.d.ts",
+ "ts5.6/buffer.buffer.d.ts",
+ "ts5.6/compatibility/float16array.d.ts",
+ "ts5.6/globals.typedarray.d.ts",
+ "ts5.7/compatibility/float16array.d.ts",
+ "tty.d.ts",
+ "url.d.ts",
+ "util.d.ts",
+ "v8.d.ts",
+ "vm.d.ts",
+ "wasi.d.ts",
+ "web-globals/abortcontroller.d.ts",
+ "web-globals/crypto.d.ts",
+ "web-globals/domexception.d.ts",
+ "web-globals/events.d.ts",
+ "web-globals/fetch.d.ts",
+ "web-globals/navigator.d.ts",
+ "web-globals/storage.d.ts",
+ "web-globals/streams.d.ts",
+ "worker_threads.d.ts",
+ "zlib.d.ts"
+ ],
+ "node-red": [
+ "func.d.ts",
+ "util.d.ts"
+ ]
+}
diff --git a/test/baselines/unresolved.json b/test/baselines/unresolved.json
new file mode 100644
index 0000000..3f4f50f
--- /dev/null
+++ b/test/baselines/unresolved.json
@@ -0,0 +1,250 @@
+{
+ "global Buffer": [],
+ "global console": [],
+ "global util": [
+ "aborted(signal)",
+ "convertProcessSignalToExitCode(signal)",
+ "transferableAbortController returns",
+ "transferableAbortSignal returns",
+ "transferableAbortSignal(signal)"
+ ],
+ "global promisify": [],
+ "global node": [],
+ "global context": [],
+ "global flow": [],
+ "global global": [],
+ "global env": [],
+ "global RED.util": [
+ "evaluateNodeProperty(node)",
+ "prepareJSONataExpression(node)"
+ ],
+ "global URL": [],
+ "global URLSearchParams": [],
+ "global setTimeout": [
+ "__promisify__"
+ ],
+ "global setInterval": [],
+ "global setImmediate": [
+ "__promisify__"
+ ],
+ "global clearTimeout": [],
+ "global queueMicrotask": [],
+ "global structuredClone": [
+ ""
+ ],
+ "module assert": [
+ "strict"
+ ],
+ "module assert/strict": [
+ "Assert",
+ "AssertOptions",
+ "AssertPredicate",
+ "AssertStrict",
+ "AssertionError",
+ "AssertionErrorOptions",
+ "CallTracker",
+ "CallTrackerCall",
+ "CallTrackerReportInformation",
+ "deepEqual",
+ "deepStrictEqual",
+ "doesNotMatch",
+ "doesNotReject",
+ "doesNotThrow",
+ "equal",
+ "fail",
+ "ifError",
+ "match",
+ "notDeepEqual",
+ "notDeepStrictEqual",
+ "notEqual",
+ "notStrictEqual",
+ "ok",
+ "partialDeepStrictEqual",
+ "rejects",
+ "strictEqual",
+ "throws"
+ ],
+ "module async_hooks": [],
+ "module buffer": [],
+ "module child_process": [],
+ "module cluster": [],
+ "module console": [],
+ "module crypto": [
+ "createCipheriv(options)",
+ "createDecipheriv(options)",
+ "createHmac(options)",
+ "createSign(options)",
+ "createVerify(options)"
+ ],
+ "module dgram": [],
+ "module dns": [
+ "promises"
+ ],
+ "module dns/promises": [
+ "Resolver new(options)",
+ "lookup(options)",
+ "resolve4(options)",
+ "resolve6(options)"
+ ],
+ "module domain": [],
+ "module events": [
+ "addAbortListener(signal)",
+ "getEventListeners(emitter)",
+ "getMaxListeners(emitter)",
+ "listenerCount(emitter)",
+ "on(emitter)",
+ "once(emitter)"
+ ],
+ "module fs": [
+ "appendFile(options)",
+ "appendFileSync(options)",
+ "promises",
+ "writeFile(options)",
+ "writeFileSync(options)"
+ ],
+ "module fs/promises": [
+ "access(path)",
+ "appendFile(options)",
+ "appendFile(path)",
+ "chmod(mode)",
+ "chmod(path)",
+ "chown(path)",
+ "constants",
+ "copyFile(dest)",
+ "copyFile(src)",
+ "cp(opts)",
+ "glob(options)",
+ "lchmod(mode)",
+ "lchmod(path)",
+ "lchown(path)",
+ "link(existingPath)",
+ "link(newPath)",
+ "lstat(opts)",
+ "lstat(path)",
+ "lutimes(atime)",
+ "lutimes(mtime)",
+ "lutimes(path)",
+ "mkdir(options)",
+ "mkdir(path)",
+ "mkdtemp(options)",
+ "mkdtempDisposable(options)",
+ "mkdtempDisposable(prefix)",
+ "open(mode)",
+ "open(path)",
+ "opendir(options)",
+ "opendir(path)",
+ "readFile(options)",
+ "readFile(path)",
+ "readdir(options)",
+ "readdir(path)",
+ "readlink(options)",
+ "readlink(path)",
+ "realpath(options)",
+ "realpath(path)",
+ "rename(newPath)",
+ "rename(oldPath)",
+ "rm(options)",
+ "rm(path)",
+ "rmdir(options)",
+ "rmdir(path)",
+ "stat(opts)",
+ "stat(path)",
+ "statfs(opts)",
+ "statfs(path)",
+ "symlink(path)",
+ "symlink(target)",
+ "truncate(path)",
+ "unlink(path)",
+ "utimes(atime)",
+ "utimes(mtime)",
+ "utimes(path)",
+ "watch(filename)",
+ "writeFile(file)",
+ "writeFile(options)"
+ ],
+ "module http": [
+ "CloseEvent",
+ "IncomingMessage new(socket)",
+ "MessageEvent",
+ "WebSocket"
+ ],
+ "module http2": [
+ "Http2ServerRequest new(options)",
+ "OutgoingHttpHeaders",
+ "performServerHandshake(socket)"
+ ],
+ "module https": [
+ "Server new(requestListener)",
+ "createServer(requestListener)",
+ "get returns",
+ "request returns"
+ ],
+ "module module": [],
+ "module net": [],
+ "module os": [
+ "arch returns",
+ "platform returns"
+ ],
+ "module path": [],
+ "module perf_hooks": [],
+ "module process": [
+ "channel",
+ "emit(sendHandle)",
+ "loadEnvFile(path)",
+ "mainModule",
+ "send(options)",
+ "send(sendHandle)"
+ ],
+ "module querystring": [],
+ "module readline": [
+ "promises"
+ ],
+ "module stream": [
+ "addAbortSignal(signal)",
+ "promises"
+ ],
+ "module stream/consumers": [
+ "arrayBuffer(stream)",
+ "blob(stream)",
+ "buffer(stream)",
+ "bytes(stream)",
+ "json(stream)",
+ "text(stream)"
+ ],
+ "module stream/promises": [
+ "pipeline returns",
+ "pipeline(options)"
+ ],
+ "module stream/web": [],
+ "module string_decoder": [],
+ "module timers": [
+ "promises"
+ ],
+ "module timers/promises": [],
+ "module tls": [
+ "TLSSocket new(socket)"
+ ],
+ "module trace_events": [],
+ "module tty": [
+ "ReadStream new(options)"
+ ],
+ "module url": [
+ "urlToHttpOptions returns"
+ ],
+ "module util": [
+ "aborted(signal)",
+ "convertProcessSignalToExitCode(signal)",
+ "transferableAbortController returns",
+ "transferableAbortSignal returns",
+ "transferableAbortSignal(signal)"
+ ],
+ "module v8": [
+ "getHeapSnapshot returns"
+ ],
+ "module vm": [],
+ "module wasi": [],
+ "module worker_threads": [
+ "moveMessagePortToContext(contextifiedSandbox)"
+ ],
+ "module zlib": []
+}
diff --git a/test/browser/editor.test.js b/test/browser/editor.test.js
new file mode 100644
index 0000000..016e827
--- /dev/null
+++ b/test/browser/editor.test.js
@@ -0,0 +1,246 @@
+/**
+ * Loads the built editor (output/monaco/dist) in headless Chromium.
+ *
+ * Catches what the other suites can't see from Node:
+ * - the bundle or a language worker failing at runtime (the build succeeds either way)
+ * - a locale file loading but no longer translating anything
+ * - test/lib/editor-env.js disagreeing with monaco's real TS worker. The type tests are only
+ * worth something if that emulation matches the editor, so this compares the two directly.
+ *
+ * Needs a build and a browser: `npm run build`, `npx playwright install chromium`, then
+ * `npm run test:browser`.
+ */
+const { describe, it, before, after } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const http = require("http");
+const path = require("path");
+const ts = require("typescript");
+const { chromium } = require("playwright");
+const { projectDir, targetDir, monacoDir } = require("../../setup");
+const { knownModules, defaultServerSideTypes } = require("../fixtures/editor-modules");
+const { probeTypes } = require("../lib/editor-env");
+
+const typesDir = path.join(targetDir, "types");
+const LOCALES = fs.readdirSync(path.join(targetDir, "monaco", "dist", "locale")).filter((f) => /^[a-z-]+\.js$/.test(f)).map((f) => f.slice(0, -3));
+
+/**
+ * Locales that load but don't translate. Each is asserted to still be broken, so a fix upstream
+ * fails the test and the entry can go.
+ */
+const KNOWN_UNTRANSLATED = {
+ // monaco-editor has published esm/vs/nls/lang/pt-br.js with every message null since at
+ // least 0.55.1 (about 11 KB, against 130 KB or more for the other languages).
+ "pt-br": "monaco-editor ships an empty pt-br message table",
+};
+
+const TYPES = { ".js": "text/javascript", ".json": "application/json", ".ts": "text/plain", ".ttf": "font/ttf", ".css": "text/css" };
+
+/** Serves the repo root plus a bare page that loads the bundle (and optionally a locale). */
+function startServer() {
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, "http://localhost");
+ if (url.pathname === "/harness.html") {
+ const lang = url.searchParams.get("lang");
+ res.writeHead(200, { "content-type": "text/html" });
+ res.end(`
+
+ ${lang ? `` : ""}
+
+
+ `);
+ return;
+ }
+ const file = path.join(projectDir, decodeURIComponent(url.pathname));
+ if (!file.startsWith(targetDir) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
+ res.writeHead(404).end();
+ return;
+ }
+ res.writeHead(200, { "content-type": TYPES[path.extname(file)] || "application/octet-stream" });
+ fs.createReadStream(file).pipe(res);
+ });
+ return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
+}
+
+describe("built editor in a browser", () => {
+ let server, browser, base;
+
+ before(async () => {
+ assert.ok(fs.existsSync(path.join(targetDir, "monaco", "dist", "editor.js")), "no build found, run `npm run build` first");
+ server = await startServer();
+ base = `http://127.0.0.1:${server.address().port}`;
+ browser = await chromium.launch();
+ });
+
+ after(async () => {
+ await browser?.close();
+ server?.close();
+ });
+
+ async function openEditor(lang) {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.goto(`${base}/harness.html${lang ? `?lang=${lang}` : ""}`);
+ await page.waitForFunction(() => typeof window.monaco === "object");
+ return { page, errors };
+ }
+
+ it("loads the bundle with the expected monaco and TypeScript versions", async () => {
+ const { page, errors } = await openEditor();
+ const info = await page.evaluate(() => ({ monaco: monaco.version, ts: monaco.typescript.typescriptVersion, errors: window.__errors }));
+ const expected = JSON.parse(fs.readFileSync(path.join(monacoDir, "package.json"), "utf8")).version;
+ assert.equal(info.monaco, expected);
+ assert.equal(info.ts, ts.version, "the tests' typescript should match the one in the bundle");
+ assert.deepEqual([...errors, ...info.errors], []);
+ await page.close();
+ });
+
+ it("runs every language worker", async () => {
+ const { page, errors } = await openEditor();
+ // Each worker proves itself by doing its job: diagnostics for ts/json/css, formatting
+ // for html. A worker that fails to load just never answers.
+ const result = await page.evaluate(async () => {
+ const waitFor = async (fn, ms = 20000) => {
+ const end = Date.now() + ms;
+ while (Date.now() < end) {
+ const v = await fn();
+ if (v) return v;
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ return null;
+ };
+ const markers = (lang, value) => {
+ const model = monaco.editor.createModel(value, lang);
+ return waitFor(() => monaco.editor.getModelMarkers({ resource: model.uri }).length > 0);
+ };
+ // The html formatter registers lazily, so keep asking until it does something.
+ const ed = monaco.editor.create(document.getElementById("ed"), { value: "", language: "html" });
+ const html = await waitFor(async () => {
+ await ed.getAction("editor.action.formatDocument").run();
+ return ed.getValue().includes("\n");
+ });
+ return {
+ typescript: !!(await markers("javascript", "const = ;")),
+ json: !!(await markers("json", "{ \"a\": }")),
+ css: !!(await markers("css", "body { color: ; }")),
+ html: !!html,
+ };
+ });
+ assert.deepEqual(result, { typescript: true, json: true, css: true, html: true });
+ assert.deepEqual(errors, []);
+ await page.close();
+ });
+
+ it("translates the UI for every locale", async () => {
+ const label = async (lang) => {
+ const { page, errors } = await openEditor(lang);
+ const text = await page.evaluate(() => monaco.editor.create(document.getElementById("ed"), { language: "javascript" }).getAction("editor.action.formatDocument").label);
+ assert.deepEqual(errors, [], `${lang || "en"} page errors`);
+ await page.close();
+ return text;
+ };
+ const english = await label();
+ assert.equal(english, "Format Document");
+ const untranslated = [];
+ for (const lang of LOCALES) {
+ if ((await label(lang)) === english) untranslated.push(lang);
+ }
+ assert.ok(LOCALES.length >= 10, `only found locales: ${LOCALES.join(", ")}`);
+ assert.deepEqual(untranslated.filter((l) => !KNOWN_UNTRANSLATED[l]), [], "these locales load but show English");
+ const fixed = Object.keys(KNOWN_UNTRANSLATED).filter((l) => LOCALES.includes(l) && !untranslated.includes(l));
+ assert.deepEqual(fixed, [], "these are translated now, remove them from KNOWN_UNTRANSLATED");
+ });
+
+ describe("monaco's TS worker agrees with test/lib/editor-env.js", () => {
+ /**
+ * Loads libs the way node-red's monaco.js does, then reads each probe's type from the
+ * worker's hover text and its diagnostics, for comparison with the Node emulation.
+ */
+ async function inBrowser({ defaults, modules = [], probes }) {
+ const { page, errors } = await openEditor();
+ const libs = [];
+ const load = (m) => libs.push({
+ uri: `file://types/${m.package}/${m.module}/index.d.ts`,
+ text: fs.existsSync(path.join(typesDir, m.path)) ? fs.readFileSync(path.join(typesDir, m.path), "utf8") : `/* Failed to load 'types/${m.path}' */\n`,
+ });
+ defaults.forEach((key) => load(knownModules[key]));
+ modules.forEach((lib) => load(knownModules[lib.module] || { package: "other", module: lib.module, path: `other/${lib.module}.d.ts` }));
+ const shim = modules.length
+ ? modules.map((l) => `import ${l.var}_import = require('${l.module}');\n`).join("") + "\ndeclare global {\n" + modules.map((l) => `var ${l.var}: typeof ${l.var}_import;\n`).join("") + "\n}"
+ : null;
+ const code = probes.map((expr, i) => `const __probe_${i} = (${expr});\n`).join("");
+
+ const out = await page.evaluate(async ({ libs, shim, code, count }) => {
+ const t = monaco.typescript;
+ t.javascriptDefaults.setCompilerOptions({
+ allowJs: true, checkJs: true, allowNonTsExtensions: true, target: t.ScriptTarget.ESNext,
+ strictNullChecks: false, strictPropertyInitialization: true, strictFunctionTypes: true,
+ strictBindCallApply: true, useDefineForClassFields: true,
+ moduleResolution: t.ModuleResolutionKind.NodeJs, module: t.ModuleKind.CommonJS,
+ typeRoots: ["types"], lib: ["esnext"],
+ });
+ libs.forEach((l) => t.javascriptDefaults.addExtraLib(l.text, l.uri));
+ if (shim) t.javascriptDefaults.addExtraLib(shim, "file://types/extraModuleLibs/index.d.ts");
+ const model = monaco.editor.createModel(code, "javascript", monaco.Uri.parse("file:///src/function.js"));
+ const worker = await (await t.getJavaScriptWorker())(model.uri);
+ const uri = model.uri.toString();
+ const types = [];
+ for (let i = 0; i < count; i++) {
+ const info = await worker.getQuickInfoAtPosition(uri, code.indexOf(`__probe_${i} `) + 1);
+ const text = (info?.displayParts || []).map((p) => p.text).join("");
+ types.push(text.replace(/^const __probe_\d+: /, ""));
+ }
+ const diags = [...await worker.getSyntacticDiagnostics(uri), ...await worker.getSemanticDiagnostics(uri)];
+ return { types, codes: diags.map((d) => d.code).sort() };
+ }, { libs, shim, code, count: probes.length });
+ assert.deepEqual(errors, []);
+ await page.close();
+ return out;
+ }
+
+ function inNode({ defaults, modules = [], probes }) {
+ const original = [...defaultServerSideTypes];
+ defaultServerSideTypes.splice(0, defaultServerSideTypes.length, ...defaults);
+ try {
+ const r = probeTypes({ typesDir, modules, probes });
+ const codes = r.diagnostics.filter((d) => d.startsWith("/src/function.js")).map((d) => Number(/TS(\d+)/.exec(d)[1])).sort();
+ return { types: r.results.map((x) => x.type), codes };
+ } finally {
+ defaultServerSideTypes.splice(0, defaultServerSideTypes.length, ...original);
+ }
+ }
+
+ const PROBES = [
+ 'Buffer.from("abc")', "Buffer.alloc(4).readUInt8(0)", "/** @type {NodeJS.TypedArray} */ (undefined)",
+ 'setTimeout(() => {}, 1)', 'new URL("http://x").hostname', 'util.format("x")', "msg._msgid",
+ 'node.linkcall("t", {})', "RED.util.generateId()", "context.keys()", "process",
+ ];
+
+ const cases = [
+ { name: "default libs", defaults: defaultServerSideTypes, probes: PROBES },
+ {
+ // The old default list: Buffer and NodeJS.TypedArray break. Both sides must agree.
+ name: "default libs without buffer.buffer and globals.typedarray",
+ defaults: defaultServerSideTypes.filter((k) => k !== "buffer.buffer" && k !== "globals.typedarray"),
+ probes: PROBES,
+ },
+ {
+ name: "Setup tab modules fs, os, assert/strict and test",
+ defaults: defaultServerSideTypes,
+ modules: [{ var: "fs", module: "fs" }, { var: "os", module: "os" }, { var: "strict", module: "assert/strict" }, { var: "nodeTest", module: "test" }],
+ probes: ['fs.readFileSync("a", "utf8")', "os.hostname()", "strict.equal", "nodeTest"],
+ },
+ ];
+
+ for (const c of cases) {
+ it(c.name, async () => {
+ const browserSide = await inBrowser(c);
+ const nodeSide = inNode(c);
+ const pairs = c.probes.map((expr, i) => `${expr} => ${browserSide.types[i]}`);
+ assert.deepEqual(c.probes.map((expr, i) => `${expr} => ${nodeSide.types[i]}`), pairs, "probe types differ (+ node emulation, - monaco's worker)");
+ assert.deepEqual(nodeSide.codes, browserSide.codes, "diagnostic codes differ (+ node emulation, - monaco's worker)");
+ });
+ }
+ });
+});
diff --git a/test/build-output/build-output.test.js b/test/build-output/build-output.test.js
new file mode 100644
index 0000000..ba3fa36
--- /dev/null
+++ b/test/build-output/build-output.test.js
@@ -0,0 +1,56 @@
+/**
+ * Smoke test for a finished build: the files node-red's `npm run copy` picks up are all there.
+ *
+ * Run with `npm run test:build`, which does a full `npm run build` first and then runs the
+ * other suites against output/types too. Needs network access (npm and the vscode-loc download).
+ */
+const { describe, it } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const path = require("path");
+const { targetDir } = require("../../setup");
+
+const dist = path.join(targetDir, "monaco", "dist");
+
+/** Smallest plausible size for each bundle. Real sizes are 0.3 to 7 MB. */
+const BUNDLES = {
+ "editor.js": 1_000_000,
+ "editor.worker.js": 100_000,
+ "ts.worker.js": 1_000_000,
+ "json.worker.js": 100_000,
+ "css.worker.js": 100_000,
+ "html.worker.js": 100_000,
+};
+
+/** Locales the build has always produced (monaco's vscode-loc languages). */
+const LOCALES = ["cs", "de", "es", "fr", "it", "ja", "ko", "pl", "pt-br", "ru", "tr", "zh-cn", "zh-tw"];
+
+const size = (f) => (fs.existsSync(f) ? fs.statSync(f).size : 0);
+
+describe("build output", () => {
+ it("has the editor bundle and every language worker", () => {
+ const problems = Object.entries(BUNDLES)
+ .filter(([name, min]) => size(path.join(dist, name)) < min)
+ .map(([name, min]) => `${name}: ${size(path.join(dist, name))} bytes, expected at least ${min}`);
+ assert.deepEqual(problems, []);
+ });
+
+ it("has a locale file for every language", () => {
+ const missing = LOCALES.filter((l) => size(path.join(dist, "locale", `${l}.js`)) === 0);
+ assert.deepEqual(missing, []);
+ });
+
+ it("has the themes", () => {
+ const themes = fs.readdirSync(path.join(dist, "theme")).filter((f) => f.endsWith(".json"));
+ assert.ok(themes.length >= 50, `expected at least 50 themes, found ${themes.length}`);
+ for (const t of themes) {
+ assert.doesNotThrow(() => JSON.parse(fs.readFileSync(path.join(dist, "theme", t), "utf8")), `theme/${t} is not valid JSON`);
+ }
+ });
+
+ it("has the node and node-red types", () => {
+ for (const f of ["node/globals.d.ts", "node/buffer.d.ts", "node-red/func.d.ts", "node-red/util.d.ts"]) {
+ assert.ok(size(path.join(targetDir, "types", f)) > 0, `output/types/${f} is missing or empty`);
+ }
+ });
+});
diff --git a/test/bundle-content.test.js b/test/bundle-content.test.js
new file mode 100644
index 0000000..2151e80
--- /dev/null
+++ b/test/bundle-content.test.js
@@ -0,0 +1,78 @@
+/**
+ * What ends up in output/types, and what deliberately doesn't.
+ *
+ * update-types.js drops some @types/node files by name (excludeLibs). When @types/node renames
+ * or splits a file, the exclusion silently stops working; that is how the ~4400 line
+ * inspector.generated.d.ts got back into the bundle. These tests make that loud.
+ */
+const { describe, it } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const path = require("path");
+const { excludeLibs } = require("../update-types");
+const { NODE_LIB_SOURCE } = require("../setup");
+const { knownModules, notBuiltHere } = require("./fixtures/editor-modules");
+const { getTypesDir } = require("./lib/types-dir");
+const baseline = require("./lib/baseline");
+
+const typesDir = getTypesDir();
+
+/** Relative paths (forward slashes) of every .ts file under dir. */
+function listDts(dir) {
+ return fs.readdirSync(dir, { recursive: true })
+ .map((f) => f.split(path.sep).join("/"))
+ .filter((f) => f.includes(".ts") && fs.statSync(path.join(dir, f)).isFile())
+ .sort();
+}
+
+/** Module names a .d.ts file declares (`declare module "x"`), without any `node:` prefix. */
+function declaredModules(file) {
+ const src = fs.readFileSync(file, "utf8");
+ return [...new Set([...src.matchAll(/^\s*declare module ["']([^"']+)["']/gm)].map((m) => m[1].replace(/^node:/, "")))];
+}
+
+describe("bundle content", () => {
+ const sourceFiles = listDts(NODE_LIB_SOURCE);
+ const shippedNode = listDts(path.join(typesDir, "node"));
+ const shippedNodeRed = listDts(path.join(typesDir, "node-red"));
+
+ it("every excludeLibs entry still matches a file in @types/node", () => {
+ // An entry that matches nothing means @types/node renamed or dropped the file, and
+ // whatever replaced it is probably shipping now. Find the new name and exclude it.
+ const unmatched = excludeLibs.filter((name) => !sourceFiles.some((f) => path.posix.basename(f) === name));
+ assert.deepEqual(unmatched, [], "these excludeLibs entries match nothing in node_modules/@types/node");
+ });
+
+ it("ships none of the excluded files", () => {
+ const leaked = shippedNode.filter((f) => excludeLibs.includes(path.posix.basename(f)));
+ assert.deepEqual(leaked, []);
+ });
+
+ it("ships no declarations for modules that only excluded files declare", () => {
+ // Catches a file that gets renamed out from under the exclude list, e.g. the V8
+ // inspector protocol moving from inspector.d.ts to inspector.generated.d.ts.
+ const excludedModules = new Set(sourceFiles
+ .filter((f) => excludeLibs.includes(path.posix.basename(f)))
+ .flatMap((f) => declaredModules(path.join(NODE_LIB_SOURCE, f))));
+ assert.ok(excludedModules.has("inspector"), "expected the excluded files to declare 'inspector'");
+ const leaked = shippedNode.flatMap((f) => declaredModules(path.join(typesDir, "node", f))
+ .filter((m) => excludedModules.has(m))
+ .map((m) => `${f} declares '${m}'`));
+ assert.deepEqual(leaked, []);
+ });
+
+ it("ships every file the editor fetches", () => {
+ const missing = Object.entries(knownModules)
+ .filter(([key]) => !notBuiltHere.includes(key))
+ .map(([key, m]) => ({ key, file: path.join(typesDir, m.path) }))
+ .filter(({ file }) => !fs.existsSync(file))
+ .map(({ key, file }) => `${key}: ${path.relative(typesDir, file)}`);
+ assert.deepEqual(missing, [], "node-red's knownModules points at files that are not in the build");
+ });
+
+ it("ships the files in test/baselines/shipped-files.json", () => {
+ // New or renamed files in @types/node show up here, so someone decides whether they
+ // belong in the editor instead of them shipping by default.
+ baseline.check("shipped-files.json", { "node": shippedNode, "node-red": shippedNodeRed });
+ });
+});
diff --git a/test/fixtures/editor-modules.js b/test/fixtures/editor-modules.js
new file mode 100644
index 0000000..6e424fb
--- /dev/null
+++ b/test/fixtures/editor-modules.js
@@ -0,0 +1,102 @@
+/**
+ * A copy of what node-red's Function node editor loads from the files this repo builds.
+ *
+ * Source: node-red `packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js`
+ * (`knownModules`, `defaultServerSideTypes` and the javascriptDefaults compiler options).
+ *
+ * The editor fetches exactly one `.d.ts` file per entry and nothing else, so this table is the
+ * contract between the two repos. Keep it in step with node-red: `npm run test:node-red-sync`
+ * compares it with a node-red checkout.
+ */
+
+const knownModules = {
+ "assert": {package: "node", module: "assert", path: "node/assert.d.ts" },
+ "assert/strict": {package: "node", module: "assert/strict", path: "node/assert/strict.d.ts" },
+ "async_hooks": {package: "node", module: "async_hooks", path: "node/async_hooks.d.ts" },
+ "buffer": {package: "node", module: "buffer", path: "node/buffer.d.ts" },
+ "buffer.buffer": {package: "node", module: "buffer.buffer", path: "node/buffer.buffer.d.ts" },
+ "child_process": {package: "node", module: "child_process", path: "node/child_process.d.ts" },
+ "cluster": {package: "node", module: "cluster", path: "node/cluster.d.ts" },
+ "console": {package: "node", module: "console", path: "node/console.d.ts" },
+ "crypto": {package: "node", module: "crypto", path: "node/crypto.d.ts" },
+ "dgram": {package: "node", module: "dgram", path: "node/dgram.d.ts" },
+ "diagnostics_channel.d": {package: "node", module: "diagnostics_channel", path: "node/diagnostics_channel.d.ts" },
+ "dns": {package: "node", module: "dns", path: "node/dns.d.ts" },
+ "dns/promises": {package: "node", module: "dns/promises", path: "node/dns/promises.d.ts" },
+ "domain": {package: "node", module: "domain", path: "node/domain.d.ts" },
+ "events": {package: "node", module: "events", path: "node/events.d.ts" },
+ "fs": {package: "node", module: "fs", path: "node/fs.d.ts" },
+ "fs/promises": {package: "node", module: "fs/promises", path: "node/fs/promises.d.ts" },
+ "globals": {package: "node", module: "globals", path: "node/globals.d.ts" },
+ "globals.typedarray": {package: "node", module: "globals.typedarray", path: "node/globals.typedarray.d.ts" },
+ "http": {package: "node", module: "http", path: "node/http.d.ts" },
+ "http2": {package: "node", module: "http2", path: "node/http2.d.ts" },
+ "https": {package: "node", module: "https", path: "node/https.d.ts" },
+ "module": {package: "node", module: "module", path: "node/module.d.ts" },
+ "net": {package: "node", module: "net", path: "node/net.d.ts" },
+ "os": {package: "node", module: "os", path: "node/os.d.ts" },
+ "path": {package: "node", module: "path", path: "node/path.d.ts" },
+ "perf_hooks": {package: "node", module: "perf_hooks", path: "node/perf_hooks.d.ts" },
+ "process": {package: "node", module: "process", path: "node/process.d.ts" },
+ "querystring": {package: "node", module: "querystring", path: "node/querystring.d.ts" },
+ "readline": {package: "node", module: "readline", path: "node/readline.d.ts" },
+ "stream": {package: "node", module: "stream", path: "node/stream.d.ts" },
+ "stream/consumers": {package: "node", module: "stream/consumers", path: "node/stream/consumers.d.ts" },
+ "stream/promises": {package: "node", module: "stream/promises", path: "node/stream/promises.d.ts" },
+ "stream/web": {package: "node", module: "stream/web", path: "node/stream/web.d.ts" },
+ "string_decoder": {package: "node", module: "string_decoder", path: "node/string_decoder.d.ts" },
+ "test": {package: "node", module: "test", path: "node/test.d.ts" },
+ "timers": {package: "node", module: "timers", path: "node/timers.d.ts" },
+ "timers/promises": {package: "node", module: "timers/promises", path: "node/timers/promises.d.ts" },
+ "tls": {package: "node", module: "tls", path: "node/tls.d.ts" },
+ "trace_events": {package: "node", module: "trace_events", path: "node/trace_events.d.ts" },
+ "tty": {package: "node", module: "tty", path: "node/tty.d.ts" },
+ "url": {package: "node", module: "url", path: "node/url.d.ts" },
+ "util": {package: "node", module: "util", path: "node/util.d.ts" },
+ "v8": {package: "node", module: "v8", path: "node/v8.d.ts" },
+ "vm": {package: "node", module: "vm", path: "node/vm.d.ts" },
+ "wasi": {package: "node", module: "wasi", path: "node/wasi.d.ts" },
+ "worker_threads": {package: "node", module: "worker_threads", path: "node/worker_threads.d.ts" },
+ "zlib": {package: "node", module: "zlib", path: "node/zlib.d.ts" },
+ "node-red": {package: "node-red", module: "node-red", path: "node-red/index.d.ts" },
+ "node-red-util": {package: "node-red", module: "util", path: "node-red/util.d.ts" },
+ "node-red-func": {package: "node-red", module: "func", path: "node-red/func.d.ts" },
+};
+
+/** Keys of `knownModules` that the editor loads into every Function node editor. */
+const defaultServerSideTypes = ["node-red-util", "node-red-func", "globals", "globals.typedarray", "console", "buffer", "buffer.buffer", "timers", "url", "util"];
+
+/**
+ * `knownModules` entries that are not loaded from this repo's output:
+ * `node-red/index.d.ts` only exists when node-red's own grunt build concatenates the node-red types.
+ */
+const notBuiltHere = ["node-red"];
+
+/**
+ * The javascriptDefaults compiler options the editor sets, with monaco's enums swapped for the
+ * matching `typescript` ones.
+ * @param {typeof import('typescript')} ts
+ */
+function compilerOptions(ts) {
+ return {
+ allowJs: true,
+ checkJs: true,
+ allowNonTsExtensions: true,
+ target: ts.ScriptTarget.ESNext,
+ strictNullChecks: false,
+ strictPropertyInitialization: true,
+ strictFunctionTypes: true,
+ strictBindCallApply: true,
+ useDefineForClassFields: true,
+ moduleResolution: ts.ModuleResolutionKind.Node10,
+ module: ts.ModuleKind.CommonJS,
+ typeRoots: ["types"],
+ lib: ["lib.esnext.d.ts"],
+ // monaco's worker host cannot list directories, so it never auto-includes type packages.
+ // Say so explicitly here so nothing under node_modules/@types leaks into the tests.
+ types: [],
+ noEmit: true,
+ };
+}
+
+module.exports = { knownModules, defaultServerSideTypes, notBuiltHere, compilerOptions };
diff --git a/test/lib/baseline.js b/test/lib/baseline.js
new file mode 100644
index 0000000..556b3cb
--- /dev/null
+++ b/test/lib/baseline.js
@@ -0,0 +1,43 @@
+/**
+ * Committed JSON baselines under test/baselines/.
+ *
+ * A check fails on any difference in either direction: new entries are regressions, and missing
+ * entries mean the baseline is stale and should be refreshed so it keeps catching regressions.
+ * Run with UPDATE_BASELINE=1 (`npm run test:update-baseline`) to rewrite the files, then review
+ * the git diff.
+ */
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const path = require("path");
+
+const dir = path.join(__dirname, "..", "baselines");
+
+/** @param {string} name @param {Record} actual */
+function check(name, actual) {
+ const file = path.join(dir, name);
+ if (process.env.UPDATE_BASELINE) {
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(actual, null, 4) + "\n");
+ return;
+ }
+ assert.ok(fs.existsSync(file), `${name} is missing. Create it with \`npm run test:update-baseline\``);
+ const expected = JSON.parse(fs.readFileSync(file, "utf8"));
+ const lines = [];
+ for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
+ const exp = expected[key] || [];
+ const act = actual[key] || [];
+ const added = act.filter((x) => !exp.includes(x));
+ const removed = exp.filter((x) => !act.includes(x));
+ if (!(key in actual)) lines.push(` ${key}: no longer checked`);
+ if (!(key in expected)) lines.push(` ${key}: not in the baseline`);
+ added.forEach((x) => lines.push(` + ${key}: ${x}`));
+ removed.forEach((x) => lines.push(` - ${key}: ${x}`));
+ }
+ assert.ok(lines.length === 0, [
+ `${name} does not match (+ new, - gone):`,
+ ...lines,
+ "If the change is expected, run `npm run test:update-baseline` and commit the diff.",
+ ].join("\n"));
+}
+
+module.exports = { check };
diff --git a/test/lib/editor-env.js b/test/lib/editor-env.js
new file mode 100644
index 0000000..c603bbf
--- /dev/null
+++ b/test/lib/editor-env.js
@@ -0,0 +1,165 @@
+/**
+ * Rebuilds, with the TypeScript compiler API, the program that monaco's TS worker sees in
+ * node-red's Function node editor.
+ *
+ * It copies what monaco.js does:
+ * - every loaded `.d.ts` is added on its own as `file://types///index.d.ts`
+ * (here `/types///index.d.ts`), so a lib file only ever sees the other lib
+ * files the editor happened to load
+ * - a lib the editor fails to fetch becomes a comment, like the editor's 404 handling
+ * - modules added on the Setup tab go through the same `import x_import = require('m')` and
+ * `declare global { var x: typeof x_import }` shim as `refreshModuleLibs()`
+ * - user code is a checkJs JavaScript file
+ *
+ * The editor hides 2307 (cannot find module) and 2339 (property does not exist) from users, so
+ * a broken lib shows up as `any` with no error. The checks here look for exactly that.
+ */
+const fs = require("fs");
+const path = require("path");
+const ts = require("typescript");
+const { knownModules, defaultServerSideTypes, compilerOptions } = require("../fixtures/editor-modules");
+
+const SHIM_FILE = "/types/extraModuleLibs/index.d.ts";
+const USER_FILE = "/src/function.js";
+
+/**
+ * @param {object} opts
+ * @param {string} opts.typesDir - folder holding `node/` and `node-red/` (like output/types)
+ * @param {{var: string, module: string}[]} [opts.modules] - Setup tab modules
+ * @param {string} [opts.code] - Function node code
+ */
+function createEditorProgram({ typesDir, modules = [], code = "" }) {
+ const files = new Map();
+ const missing = [];
+
+ function loadLib(mod) {
+ const fileName = `/types/${mod.package}/${mod.module}/index.d.ts`;
+ const src = path.join(typesDir, mod.path);
+ if (fs.existsSync(src)) {
+ files.set(fileName, fs.readFileSync(src, "utf8"));
+ } else {
+ missing.push(mod.path);
+ files.set(fileName, `/* Failed to load 'types/${mod.path}' */\n`);
+ }
+ }
+
+ defaultServerSideTypes.forEach((key) => loadLib(knownModules[key]));
+
+ if (modules.length) {
+ const imports = [];
+ const defs = [];
+ for (const lib of modules) {
+ imports.push(`import ${lib.var}_import = require('${lib.module}');\n`);
+ defs.push(`var ${lib.var}: typeof ${lib.var}_import;\n`);
+ loadLib(knownModules[lib.module] || { package: "other", module: lib.module, path: `other/${lib.module}.d.ts` });
+ }
+ files.set(SHIM_FILE, imports.join("") + "\ndeclare global {\n" + defs.join("") + "\n}");
+ }
+
+ files.set(USER_FILE, code);
+
+ const options = compilerOptions(ts);
+ const host = ts.createCompilerHost(options, true);
+ const realReadFile = host.readFile;
+ const realFileExists = host.fileExists;
+ const libDir = path.dirname(ts.getDefaultLibFilePath(options));
+ const isTsLib = (f) => path.resolve(f).startsWith(path.resolve(libDir));
+ const norm = (f) => f.replace(/\\/g, "/").replace(/^[a-zA-Z]:/, "");
+
+ host.fileExists = (f) => files.has(norm(f)) || (isTsLib(f) && realFileExists(f));
+ host.readFile = (f) => (files.has(norm(f)) ? files.get(norm(f)) : isTsLib(f) ? realReadFile(f) : undefined);
+ host.getSourceFile = (f, languageVersion) => {
+ const text = host.readFile(f);
+ return text === undefined ? undefined : ts.createSourceFile(f, text, languageVersion, true);
+ };
+ host.directoryExists = (d) => isTsLib(d) || [...files.keys()].some((f) => f.startsWith(norm(d).replace(/\/?$/, "/")));
+ host.getDirectories = () => [];
+ host.getCurrentDirectory = () => "/src";
+ host.writeFile = () => {};
+
+ const program = ts.createProgram([...files.keys()], options, host);
+ return { program, checker: program.getTypeChecker(), missing };
+}
+
+/** Diagnostics for the files a user can see: their code and the Setup tab shim. */
+function userDiagnostics(program) {
+ return [USER_FILE, SHIM_FILE]
+ .map((f) => program.getSourceFile(f))
+ .filter(Boolean)
+ .flatMap((sf) => [...program.getSyntacticDiagnostics(sf), ...program.getSemanticDiagnostics(sf)])
+ .map(formatDiagnostic);
+}
+
+function formatDiagnostic(d) {
+ const msg = ts.flattenDiagnosticMessageText(d.messageText, " ");
+ if (!d.file) return `TS${d.code}: ${msg}`;
+ const { line } = d.file.getLineAndCharacterOfPosition(d.start);
+ return `${d.file.fileName}:${line + 1} TS${d.code}: ${msg}`;
+}
+
+/** The checker's error type: what an unresolved name or module turns into. Prints as `any`. */
+function isErrorType(type) {
+ return !!(type.flags & ts.TypeFlags.Any) && type.intrinsicName === "error";
+}
+
+/**
+ * Evaluates expressions as if typed into a Function node and returns the type of each one.
+ * Wrap a type with JSDoc to probe types rather than values:
+ * `/** @type {NodeJS.TypedArray} *\/ (undefined)`.
+ * @param {object} opts - as createEditorProgram, plus `probes: string[]`
+ */
+function probeTypes({ probes, ...opts }) {
+ const code = probes.map((expr, i) => `const __probe_${i} = (${expr});\n`).join("");
+ const { program, checker, missing } = createEditorProgram({ ...opts, code });
+ const sf = program.getSourceFile(USER_FILE);
+ const results = [];
+ sf.statements.forEach((stmt, i) => {
+ const decl = stmt.declarationList.declarations[0];
+ const type = checker.getTypeAtLocation(decl.initializer);
+ results.push({
+ expr: probes[i],
+ // Printed like the editor's hover text, relative to the user's code.
+ type: checker.typeToString(type, decl, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope),
+ any: !!(type.flags & ts.TypeFlags.Any),
+ unresolved: isErrorType(type),
+ });
+ });
+ return { results, diagnostics: userDiagnostics(program), missing, program, checker };
+}
+
+/**
+ * Lists the places in a module's API where a type failed to resolve: exported members, their
+ * call/construct signature parameters and return types, one level deep. Each one shows up in
+ * the editor as `any` with no warning.
+ * @param {ts.TypeChecker} checker
+ * @param {ts.Type} moduleType
+ * @param {ts.Node} location
+ */
+function findUnresolved(checker, moduleType, location) {
+ const found = new Set();
+ const visitSignatures = (owner, type) => {
+ for (const kind of [ts.SignatureKind.Call, ts.SignatureKind.Construct]) {
+ // Overloads share a label so a new overload upstream doesn't reshuffle the baseline.
+ checker.getSignaturesOfType(type, kind).forEach((sig) => {
+ const label = `${owner}${kind === ts.SignatureKind.Construct ? " new" : ""}`;
+ sig.getParameters().forEach((p) => {
+ if (isErrorType(checker.getTypeOfSymbolAtLocation(p, location))) found.add(`${label}(${p.getName()})`);
+ });
+ if (isErrorType(sig.getReturnType())) found.add(`${label} returns`);
+ });
+ }
+ };
+ if (isErrorType(moduleType)) return [""];
+ visitSignatures("", moduleType);
+ for (const prop of checker.getPropertiesOfType(moduleType)) {
+ const t = checker.getTypeOfSymbolAtLocation(prop, location);
+ if (isErrorType(t)) {
+ found.add(prop.getName());
+ continue;
+ }
+ visitSignatures(prop.getName(), t);
+ }
+ return [...found].sort();
+}
+
+module.exports = { createEditorProgram, probeTypes, findUnresolved, userDiagnostics, isErrorType, USER_FILE, SHIM_FILE };
diff --git a/test/lib/types-dir.js b/test/lib/types-dir.js
new file mode 100644
index 0000000..5faddde
--- /dev/null
+++ b/test/lib/types-dir.js
@@ -0,0 +1,38 @@
+/**
+ * Where the tests find the generated types.
+ *
+ * By default the real copy step from update-types.js runs against the installed
+ * `@types/node` and `node-red-types/`, into a temp folder. That needs no network and no prior
+ * build. Set NR_MONACO_TYPES_DIR (e.g. to `output/types`) to test an existing build instead;
+ * `npm run test:build` does that after a full build.
+ */
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { copyFiles } = require("../../update-types");
+const { NODE_LIB_SOURCE, NODE_RED_LIB_SOURCE } = require("../../setup");
+
+let cached;
+
+function getTypesDir() {
+ if (cached) return cached;
+ if (process.env.NR_MONACO_TYPES_DIR) {
+ cached = path.resolve(process.env.NR_MONACO_TYPES_DIR);
+ if (!fs.existsSync(cached)) throw new Error(`NR_MONACO_TYPES_DIR '${cached}' does not exist`);
+ return cached;
+ }
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nr-monaco-types-"));
+ const log = console.log;
+ console.log = () => {}; // copyFiles logs every file
+ try {
+ copyFiles(NODE_LIB_SOURCE, path.join(dir, "node"));
+ copyFiles(NODE_RED_LIB_SOURCE, path.join(dir, "node-red"));
+ } finally {
+ console.log = log;
+ }
+ process.on("exit", () => fs.rmSync(dir, { recursive: true, force: true }));
+ cached = dir;
+ return cached;
+}
+
+module.exports = { getTypesDir };
diff --git a/test/node-red-sync/node-red-sync.test.js b/test/node-red-sync/node-red-sync.test.js
new file mode 100644
index 0000000..3834677
--- /dev/null
+++ b/test/node-red-sync/node-red-sync.test.js
@@ -0,0 +1,84 @@
+/**
+ * Is the node-red this build is going into in step with this repo?
+ *
+ * node-red-types/*.d.ts are edited here and copied into node-red by `npm run copy`. When node-red's
+ * copies get changed directly instead (that is how `node.linkcall()` and a stale
+ * `RED.util.linkcall` drifted), that change has to come back here or the next copy undoes it.
+ * And test/fixtures/editor-modules.js is a copy of what node-red's monaco.js loads, so the type
+ * tests are only as good as that copy. Expect differences while the matching node-red PR is open.
+ *
+ * This compares both with node-red. Not part of `npm test`, because the answer depends on
+ * another repo's state. Run `npm run test:node-red-sync`, with one of:
+ * NODE_RED_DIR=../node-red a local checkout (whatever branch it is on)
+ * NODE_RED_REF=dev a branch, tag or sha on github.com/node-red/node-red (default: master)
+ */
+const { describe, it, before } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+const { NODE_RED_LIB_SOURCE } = require("../../setup");
+const fixture = require("../fixtures/editor-modules");
+
+const EDITOR_SRC = "packages/node_modules/@node-red/editor-client/src";
+const MONACO_JS = `${EDITOR_SRC}/js/ui/editors/code-editors/monaco.js`;
+const NOTE = /^\s*\/\* NOTE: Do not edit directly! This file is generated[^\n]*\*\/\s*/;
+
+const source = process.env.NODE_RED_DIR
+ ? { label: `local checkout ${path.resolve(process.env.NODE_RED_DIR)}`, dir: path.resolve(process.env.NODE_RED_DIR) }
+ : { label: `github.com/node-red/node-red@${process.env.NODE_RED_REF || "master"}`, ref: process.env.NODE_RED_REF || "master" };
+
+async function readNodeRed(file) {
+ if (source.dir) {
+ return fs.readFileSync(path.join(source.dir, file), "utf8");
+ }
+ const url = `https://raw.githubusercontent.com/node-red/node-red/${encodeURIComponent(source.ref)}/${file}`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`GET ${url}: ${res.status} ${res.statusText}`);
+ return res.text();
+}
+
+// Leading/trailing blank lines and trailing whitespace are noise, so they're ignored.
+const lines = (text) => text.replace(/\r\n/g, "\n").replace(NOTE, "").trim().split("\n").map((l) => l.trimEnd());
+
+/** Pulls the `knownModules` literal and `defaultServerSideTypes` keys out of monaco.js. */
+function parseMonacoJs(src) {
+ const start = src.indexOf("const knownModules = {");
+ assert.ok(start >= 0, "could not find `const knownModules = {` in monaco.js");
+ const end = src.indexOf("\n }", start);
+ const literal = src.slice(src.indexOf("{", start), end + 6);
+ // JSON round trip: objects made in another vm context fail deepStrictEqual on prototype alone.
+ const knownModules = JSON.parse(JSON.stringify(vm.runInNewContext(`(${literal})`, {}, { timeout: 1000 })));
+
+ const m = /const defaultServerSideTypes = \[(.*)\];/.exec(src);
+ assert.ok(m, "could not find `const defaultServerSideTypes = [...]` in monaco.js");
+ const defaultServerSideTypes = [...m[1].matchAll(/knownModules\["([^"]+)"\]/g)].map((x) => x[1]);
+ return { knownModules, defaultServerSideTypes };
+}
+
+describe(`in step with node-red (${source.label})`, () => {
+ for (const name of ["func.d.ts", "util.d.ts"]) {
+ it(`node-red-types/${name} matches node-red's copy`, async () => {
+ const ours = lines(fs.readFileSync(path.join(NODE_RED_LIB_SOURCE, name), "utf8"));
+ const theirs = lines(await readNodeRed(`${EDITOR_SRC}/types/node-red/${name}`));
+ // + is node-red's copy, - is ours. Either copy ours over (npm run copy) or bring
+ // node-red's change back into node-red-types/, whichever is right.
+ assert.deepEqual(theirs, ours, `node-red-types/${name} and node-red's types/node-red/${name} differ`);
+ });
+ }
+
+ describe("test/fixtures/editor-modules.js matches monaco.js", () => {
+ let parsed;
+ before(async () => { parsed = parseMonacoJs(await readNodeRed(MONACO_JS)); });
+
+ it("knownModules", () => {
+ assert.deepEqual(parsed.knownModules, fixture.knownModules,
+ "update the fixture to match monaco.js, then run `npm test` to see what changed");
+ });
+
+ it("defaultServerSideTypes", () => {
+ assert.deepEqual(parsed.defaultServerSideTypes, fixture.defaultServerSideTypes,
+ "update the fixture to match monaco.js, then run `npm test` to see what changed");
+ });
+ });
+});
diff --git a/test/run.js b/test/run.js
new file mode 100644
index 0000000..bd27913
--- /dev/null
+++ b/test/run.js
@@ -0,0 +1,30 @@
+/**
+ * Runs node:test suites with the env vars they need, without relying on the npm script shell.
+ *
+ * node test/run.js unit suites against freshly copied types
+ * node test/run.js --update-baseline rewrite test/baselines/*.json
+ * node test/run.js --output also check the existing build in output/
+ * node test/run.js --node-red-sync compare with node-red (see test/node-red-sync/)
+ * node test/run.js --browser load the built editor in Chromium (see test/browser/)
+ */
+const { spawnSync } = require("child_process");
+const path = require("path");
+
+const args = process.argv.slice(2);
+const env = { ...process.env };
+const files = ["test/*.test.js"];
+
+if (args.includes("--update-baseline")) env.UPDATE_BASELINE = "1";
+if (args.includes("--output")) {
+ env.NR_MONACO_TYPES_DIR = path.join(__dirname, "..", "output", "types");
+ files.push("test/build-output/*.test.js");
+}
+if (args.includes("--node-red-sync")) files.splice(0, files.length, "test/node-red-sync/*.test.js");
+if (args.includes("--browser")) files.splice(0, files.length, "test/browser/*.test.js");
+
+const result = spawnSync(process.execPath, ["--test", ...files], {
+ cwd: path.join(__dirname, ".."),
+ env,
+ stdio: "inherit",
+});
+process.exit(result.status === null ? 1 : result.status);
diff --git a/test/types-resolution.test.js b/test/types-resolution.test.js
new file mode 100644
index 0000000..41eb3c7
--- /dev/null
+++ b/test/types-resolution.test.js
@@ -0,0 +1,203 @@
+/**
+ * Do the types we ship actually resolve in node-red's Function node editor?
+ *
+ * Nothing here reports a missing type to the user: the editor turns broken types into `any` and
+ * hides "cannot find module" (2307) and "property does not exist" (2339). These tests compile
+ * the same files the editor loads, the same way, and fail on what the editor would hide.
+ * See test/lib/editor-env.js for how the editor is reproduced.
+ */
+const { describe, it } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const path = require("path");
+const ts = require("typescript");
+const { knownModules } = require("./fixtures/editor-modules");
+const { probeTypes, createEditorProgram, findUnresolved, userDiagnostics, USER_FILE } = require("./lib/editor-env");
+const { getTypesDir } = require("./lib/types-dir");
+const baseline = require("./lib/baseline");
+const { monacoDir } = require("../setup");
+
+const typesDir = getTypesDir();
+
+/** `knownModules` entries that only make sense as default libs, never as a Setup tab module. */
+const LIB_ONLY = ["globals", "globals.typedarray", "buffer.buffer", "node-red", "node-red-util", "node-red-func"];
+
+/**
+ * `knownModules` keys that are broken in node-red today. Each is asserted to still be broken, so
+ * when it gets fixed this test fails and the entry can be removed.
+ */
+const KNOWN_BROKEN = {
+ // test.d.ts only declares "node:test" (no bare alias), and `require("test")` fails at runtime
+ // anyway. Fix direction depends on node-red/node-red#5991.
+ "test": "keyed 'test' but test.d.ts only declares module 'node:test'",
+ // A user adding `diagnostics_channel` on the Setup tab misses this key and gets
+ // other/diagnostics_channel.d.ts (a 404). Typing the key itself is not a real module either.
+ "diagnostics_channel.d": "key has a stray '.d', so 'diagnostics_channel' falls through to other/",
+};
+
+/**
+ * Things every Function node relies on with no Setup tab modules. `expect` is matched against the
+ * type as the editor would show it on hover.
+ */
+const DEFAULT_PROBES = [
+ // Buffer: its overloads live in buffer.buffer.d.ts as of @types/node 24
+ { expr: 'Buffer.from("abc")', expect: /^Buffer },
+ { expr: "Buffer.alloc(4).readUInt8(0)", expect: /^number$/ },
+ { expr: "Buffer.concat([Buffer.alloc(1)])", expect: /^Buffer },
+ { expr: 'Buffer.isBuffer("x")', expect: /^boolean$/ },
+ // NodeJS.TypedArray / ArrayBufferView: globals.typedarray.d.ts as of @types/node 24
+ { expr: "/** @type {NodeJS.TypedArray} */ (undefined)", expect: /TypedArray/ },
+ { expr: "/** @type {NodeJS.ArrayBufferView} */ (undefined)", expect: /ArrayBufferView|TypedArray|DataView/ },
+ // timers, console, url, util
+ { expr: "setTimeout(() => {}, 1)", expect: /Timeout/ },
+ { expr: "setInterval(() => {}, 1)", expect: /Timeout/ },
+ { expr: "setImmediate(() => {})", expect: /Immediate/ },
+ { expr: 'console.log("x")', expect: /^void$/ },
+ { expr: 'new URL("http://example.com").hostname', expect: /^string$/ },
+ { expr: 'new URLSearchParams("a=1").get("a")', expect: /^string$/ },
+ { expr: 'util.format("%s", "x")', expect: /^string$/ },
+ { expr: 'util.types.isDate(new Date())', expect: /^boolean$/ },
+ { expr: "promisify", expect: /promisify/ },
+ // node-red-func / node-red-util
+ { expr: "msg._msgid", expect: /^string$/ },
+ { expr: "__msgid__", expect: /^string$/ },
+ { expr: "node.send", expect: /NodeMessage/ },
+ { expr: 'node.linkcall("target", {})', expect: /^Promise },
+ { expr: "node.status", expect: /NodeStatus/ },
+ { expr: "context.keys()", expect: /^string\[\]$/ },
+ { expr: "flow.keys()", expect: /^string\[\]$/ },
+ { expr: "global.keys()", expect: /^string\[\]$/ },
+ { expr: 'env.get("NR_NODE_ID")', expect: /^any$/, allowAny: true },
+ { expr: "RED.util.cloneMessage(msg)", expect: /^object$/ },
+ { expr: "RED.util.generateId()", expect: /^string$/ },
+];
+
+/** Spot checks for Setup tab modules, beyond "it resolves". `mod` is the module's variable. */
+const MODULE_PROBES = {
+ "fs": [{ expr: 'mod.readFileSync("a", "utf8")', expect: /^string$/ }, { expr: 'mod.existsSync("a")', expect: /^boolean$/ }],
+ "path": [{ expr: 'mod.join("a", "b")', expect: /^string$/ }],
+ "os": [{ expr: "mod.hostname()", expect: /^string$/ }, { expr: "mod.cpus().length", expect: /^number$/ }],
+ "crypto": [{ expr: 'mod.createHash("sha256").update("x").digest("hex")', expect: /^string$/ }, { expr: "mod.randomUUID()", expect: /`|string/ }],
+ "zlib": [{ expr: "mod.gzipSync(Buffer.from(\"x\"))", expect: /Buffer/ }],
+ "child_process": [{ expr: 'mod.execSync("ls")', expect: /Buffer|string/ }],
+ "events": [{ expr: "new mod().on", expect: /=>/ }],
+ "dns/promises": [{ expr: 'mod.lookup("example.com")', expect: /^Promise }],
+ "timers/promises": [{ expr: "mod.setTimeout(1)", expect: /^Promise }],
+ "querystring": [{ expr: 'mod.stringify({ a: 1 })', expect: /^string$/ }],
+ "string_decoder": [{ expr: 'new mod.StringDecoder("utf8").write(Buffer.from("x"))', expect: /^string$/ }],
+ "url": [{ expr: 'mod.fileURLToPath("file:///a")', expect: /^string$/ }],
+ "util": [{ expr: "mod.inspect({})", expect: /^string$/ }],
+ "net": [{ expr: "mod.isIP(\"1.2.3.4\")", expect: /^number$/ }],
+ "http": [{ expr: "mod.STATUS_CODES", expect: /string/ }],
+ "stream": [{ expr: "new mod.PassThrough().write", expect: /=>/ }],
+};
+
+function assertProbes({ results, diagnostics }, probes) {
+ assert.deepEqual(diagnostics, [], "the Function node code should compile cleanly");
+ results.forEach((r, i) => {
+ const p = probes[i];
+ assert.ok(!r.unresolved, `\`${r.expr}\` failed to resolve (shows as \`any\` in the editor)`);
+ if (!p.allowAny) assert.ok(!r.any, `\`${r.expr}\` is \`any\``);
+ assert.match(r.type, p.expect, `\`${r.expr}\` has type \`${r.type}\``);
+ });
+}
+
+/** Compiles `mod;` with one Setup tab module and returns what the editor would know about it. */
+function loadSetupModule(moduleName) {
+ const { program, checker, missing } = createEditorProgram({ typesDir, modules: [{ var: "mod", module: moduleName }], code: "mod;\n" });
+ const expr = program.getSourceFile(USER_FILE).statements[0].expression;
+ const type = checker.getTypeAtLocation(expr);
+ return { program, checker, missing, expr, type, diagnostics: userDiagnostics(program) };
+}
+
+function monacoTypeScriptVersion() {
+ const pkgDir = monacoDir;
+ const candidates = [
+ "esm/vs/languages/features/typescript/lib/typescriptServices.js",
+ "esm/vs/language/typescript/lib/typescriptServices.js",
+ ].map((f) => path.join(pkgDir, f));
+ const file = candidates.find((f) => fs.existsSync(f));
+ assert.ok(file, `could not find monaco's bundled typescriptServices.js (looked in ${candidates.join(", ")})`);
+ const m = /\bversion = "(\d+\.\d+\.\d+[^"]*)"/.exec(fs.readFileSync(file, "utf8"));
+ assert.ok(m, `could not read the TypeScript version from ${file}`);
+ return m[1];
+}
+
+describe("type resolution in the Function node editor", () => {
+ it("tests use the same TypeScript as monaco's TS worker", () => {
+ // Different major/minor versions can resolve the same .d.ts differently, which would make
+ // these tests disagree with the editor. Pin the `typescript` devDependency to match.
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
+ assert.equal(majorMinor(ts.version), majorMinor(monacoTypeScriptVersion()),
+ `typescript ${ts.version} is installed but monaco-editor bundles ${monacoTypeScriptVersion()}`);
+ });
+
+ it("loads every default lib", () => {
+ const { missing } = createEditorProgram({ typesDir });
+ assert.deepEqual(missing, [], "these default libs would 404 in the editor");
+ });
+
+ describe("default libs (no Setup tab modules)", () => {
+ const run = probeTypes({ typesDir, probes: DEFAULT_PROBES.map((p) => p.expr) });
+ run.results.forEach((r, i) => {
+ it(r.expr, () => assertProbes({ results: [r], diagnostics: [] }, [DEFAULT_PROBES[i]]));
+ });
+ it("compiles the probes with no errors", () => assert.deepEqual(run.diagnostics, []));
+ });
+
+ describe("Setup tab modules", () => {
+ const setupModules = Object.entries(knownModules)
+ .filter(([key, m]) => m.package === "node" && !LIB_ONLY.includes(key))
+ .map(([key]) => key);
+
+ for (const key of setupModules) {
+ if (KNOWN_BROKEN[key]) {
+ it(`${key} (known broken: ${KNOWN_BROKEN[key]})`, () => {
+ const { type, diagnostics } = loadSetupModule(key);
+ assert.ok(diagnostics.length > 0 || type.flags & ts.TypeFlags.Any,
+ `'${key}' resolves now. Remove it from KNOWN_BROKEN in ${path.basename(__filename)}`);
+ });
+ continue;
+ }
+ it(key, () => {
+ const { type, diagnostics, missing } = loadSetupModule(key);
+ assert.deepEqual(missing, [], `the editor would 404 on these files for '${key}'`);
+ assert.deepEqual(diagnostics, [], `adding '${key}' on the Setup tab should compile cleanly`);
+ assert.ok(!(type.flags & ts.TypeFlags.Any), `'${key}' resolves to \`any\``);
+ const probes = MODULE_PROBES[key];
+ if (probes) {
+ assertProbes(probeTypes({ typesDir, modules: [{ var: "mod", module: key }], probes: probes.map((p) => p.expr) }), probes);
+ }
+ });
+ }
+
+ it("has spot checks only for modules the editor knows", () => {
+ const unknown = Object.keys(MODULE_PROBES).filter((k) => !setupModules.includes(k));
+ assert.deepEqual(unknown, []);
+ });
+ });
+
+ describe("unresolved types match the baseline", () => {
+ // The editor loads one file per module, so some cross-module types never resolve (for
+ // example `http` uses types from `net` and `stream`). That is how it has always worked.
+ // This test pins the current list so a version bump can't silently add to it.
+ // Refresh with `npm run test:update-baseline` and review the diff.
+ it("matches test/baselines/unresolved.json", () => {
+ const actual = {};
+
+ const globals = ["Buffer", "console", "util", "promisify", "node", "context", "flow", "global", "env", "RED.util", "URL", "URLSearchParams", "setTimeout", "setInterval", "setImmediate", "clearTimeout", "queueMicrotask", "structuredClone"];
+ const { program, checker } = createEditorProgram({ typesDir, code: globals.map((g) => `${g};\n`).join("") });
+ program.getSourceFile(USER_FILE).statements.forEach((stmt, i) => {
+ actual[`global ${globals[i]}`] = findUnresolved(checker, checker.getTypeAtLocation(stmt.expression), stmt.expression);
+ });
+
+ for (const [key, m] of Object.entries(knownModules)) {
+ if (m.package !== "node" || LIB_ONLY.includes(key) || KNOWN_BROKEN[key]) continue;
+ const { checker, type, expr } = loadSetupModule(key);
+ actual[`module ${key}`] = findUnresolved(checker, type, expr);
+ }
+
+ baseline.check("unresolved.json", actual);
+ });
+ });
+});
diff --git a/update-types.js b/update-types.js
index 3b20855..0632177 100644
--- a/update-types.js
+++ b/update-types.js
@@ -5,7 +5,7 @@ const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const note = `\n/* NOTE: Do not edit directly! This file is generated using \`npm run update-types\` in https://github.com/node-red/nr-monaco-build */\n\n`;
-const excludeLibs = ["base.d.ts", "constants.d.ts", "index.d.ts", "inspector.d.ts", "inspector.generated.d.ts", "punycode.d.ts", "globals.global.d.ts", "repl.d.ts"];
+const excludeLibs = ["constants.d.ts", "index.d.ts", "inspector.d.ts", "inspector.generated.d.ts", "punycode.d.ts", "repl.d.ts"];
const { findClosestSemverMatch, deleteFileOrDir, mkDirSafe } = require("./common");
@@ -25,7 +25,7 @@ if (MINIFY_DTS) {
minifier = createMinifier(ts);// setup (provide a TS Compiler API object)
}
-(function () {
+function updateTypes() {
let nodeVer = NODE_VERSION_TO_INCLUDE || process.version
if (nodeVer[0] === "v") { nodeVer = nodeVer.substring(1) }
@@ -45,7 +45,13 @@ if (MINIFY_DTS) {
deleteFileOrDir(NODE_RED_LIB_DESTINATION);
copyFiles(NODE_LIB_SOURCE, NODE_LIB_DESTINATION);
copyFiles(NODE_RED_LIB_SOURCE, NODE_RED_LIB_DESTINATION);
-})();
+}
+
+if (require.main === module) {
+ updateTypes();
+}
+
+module.exports = { excludeLibs, copyFiles };
function copyFiles(src, dst) {