From 6318216725dea55e565217f14519132cecaa8608 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:38:02 +0100 Subject: [PATCH 01/14] refactor(update-types): export copyFiles and excludeLibs for tests Requiring update-types.js used to run the whole update straight away (npm view, npm i, delete and recopy output/types). Tests need to reuse the real copy logic and exclude list without those side effects. - Main run now only happens when the file is run directly - Export copyFiles and excludeLibs --- update-types.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/update-types.js b/update-types.js index 3b20855..d1da586 100644 --- a/update-types.js +++ b/update-types.js @@ -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) { From 7de202a54e91226e3e9fa0e0db9a0c1e2eb2c8aa Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:44:33 +0100 Subject: [PATCH 02/14] test: check shipped types resolve the way the Function node editor loads them The editor turns a broken type into `any` and hides "cannot find module" and "property does not exist" from users, so a bad @types/node bump builds and ships with no warning. That is how Buffer.from() ended up as `any` after @types/node 24 moved BufferConstructor into buffer.buffer.d.ts. These tests rebuild the editor's TS program with the TypeScript compiler API: the same files node-red's monaco.js loads, at the same virtual paths, with the same compiler options and Setup tab require() shim. Then they check what the editor would hide. - Pinned copy of monaco.js knownModules/defaultServerSideTypes in test/fixtures/editor-modules.js - Probes for Buffer, NodeJS.TypedArray, timers, URL, util, node, RED.util and friends in the default libs - Every knownModules entry resolves when added on the Setup tab, plus spot checks for common modules - Known broken entries ('test', 'diagnostics_channel.d') are asserted to still be broken, so fixing them flags the list for cleanup - Baseline of members that already fail to resolve (one file per module means cross-module imports are lost), so a bump can't quietly add more - Check that the installed typescript matches the version monaco bundles - `npm test` copies types to a temp folder with update-types.js, so no build or network is needed Reverting the fixture to node-red's old default list (no buffer.buffer, no globals.typedarray) fails 10 tests, including Buffer.from and NodeJS.TypedArray. --- package.json | 2 + test/baselines/unresolved.json | 250 ++++++++++++++++++++++++++++++++ test/fixtures/editor-modules.js | 102 +++++++++++++ test/lib/baseline.js | 43 ++++++ test/lib/editor-env.js | 164 +++++++++++++++++++++ test/lib/types-dir.js | 38 +++++ test/run.js | 21 +++ test/types-resolution.test.js | 203 ++++++++++++++++++++++++++ 8 files changed, 823 insertions(+) create mode 100644 test/baselines/unresolved.json create mode 100644 test/fixtures/editor-modules.js create mode 100644 test/lib/baseline.js create mode 100644 test/lib/editor-env.js create mode 100644 test/lib/types-dir.js create mode 100644 test/run.js create mode 100644 test/types-resolution.test.js diff --git a/package.json b/package.json index c4cb61d..667b544 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "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", "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/*", 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/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..02de4bc --- /dev/null +++ b/test/lib/editor-env.js @@ -0,0 +1,164 @@ +/** + * 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], + type: checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation), + 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/run.js b/test/run.js new file mode 100644 index 0000000..d979a32 --- /dev/null +++ b/test/run.js @@ -0,0 +1,21 @@ +/** + * 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 + */ +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"; + +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 {}, 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/ }], + "dns/promises": [{ expr: 'mod.lookup("example.com")', expect: /^Promise/ }], +}; + +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); + }); + }); +}); From fabd123d2eee33921c0f26f1afcfcfa6f8690b46 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:45:11 +0100 Subject: [PATCH 03/14] chore(update-types): drop exclude entries that match nothing base.d.ts is not in @types/node 20 or 24, and globals.global.d.ts went away in 24. Neither matches anything, so removing them changes nothing in the output. It does let the new bundle test fail on any entry that stops matching, which is how a rename lets an excluded file back in. --- update-types.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update-types.js b/update-types.js index d1da586..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"); From 31b50fbd73872a1b5d7679846463cabf6e3b676f Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:45:52 +0100 Subject: [PATCH 04/14] test: check what update-types.js ships and excludes When @types/node renamed the V8 inspector protocol to inspector.generated.d.ts, the exclude list kept matching the old name and the ~4400 line file shipped again with no warning. - Every excludeLibs entry must still match a file in @types/node - No excluded file name ships, at any depth - No shipped file declares a module that only excluded files declare (inspector, inspector/promises, repl, punycode, constants) - Every file in node-red's knownModules exists in the build - Baseline of shipped files, so a new or renamed file in @types/node needs a decision instead of shipping by default Taking inspector.generated.d.ts back out of excludeLibs fails two of these, both naming the file. --- test/baselines/shipped-files.json | 72 ++++++++++++++++++++++++++++ test/bundle-content.test.js | 78 +++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 test/baselines/shipped-files.json create mode 100644 test/bundle-content.test.js 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/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 }); + }); +}); From 0993c975dd421c08ed67276f5d81fe140f9e1353 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:47:19 +0100 Subject: [PATCH 05/14] test: add an opt-in check that node-red-types and the fixture match node-red node-red-types/ is copied into node-red, but node-red also takes direct commits to its copies, so the two drift in both directions (node.linkcall() missing here, a stale RED.util.linkcall still in node-red). The type tests also depend on test/fixtures/editor-modules.js matching monaco.js. `npm run test:node-red-sync` compares node-red-types/func.d.ts and util.d.ts with node-red's types/node-red/ copies, and the fixture with the knownModules and defaultServerSideTypes in monaco.js. It reads a local checkout (NODE_RED_DIR) or a ref on GitHub (NODE_RED_REF, default master). It is not part of `npm test`, since the result depends on node-red's state. Putting back the pre-sync func.d.ts from main fails with a diff showing the missing node.linkcall(). --- package.json | 1 + test/node-red-sync/node-red-sync.test.js | 83 ++++++++++++++++++++++++ test/run.js | 2 + 3 files changed, 86 insertions(+) create mode 100644 test/node-red-sync/node-red-sync.test.js diff --git a/package.json b/package.json index 667b544..f5e7f7e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "generate-extraLibs": "node generate-extraLibs.js", "test": "node test/run.js", "test:update-baseline": "node test/run.js --update-baseline", + "test:node-red-sync": "node test/run.js --node-red-sync", "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/*", 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..9f64b12 --- /dev/null +++ b/test/node-red-sync/node-red-sync.test.js @@ -0,0 +1,83 @@ +/** + * Is this repo in step with node-red? + * + * node-red-types/*.d.ts are edited here and copied into node-red, but node-red also gets direct + * commits to its copies (that is how `node.linkcall()` and a stale `RED.util.linkcall` drifted). + * 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. + * + * 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 index d979a32..fc206bf 100644 --- a/test/run.js +++ b/test/run.js @@ -3,6 +3,7 @@ * * node test/run.js unit suites against freshly copied types * node test/run.js --update-baseline rewrite test/baselines/*.json + * node test/run.js --node-red-sync compare with node-red (see test/node-red-sync/) */ const { spawnSync } = require("child_process"); const path = require("path"); @@ -12,6 +13,7 @@ const env = { ...process.env }; const files = ["test/*.test.js"]; if (args.includes("--update-baseline")) env.UPDATE_BASELINE = "1"; +if (args.includes("--node-red-sync")) files.splice(0, files.length, "test/node-red-sync/*.test.js"); const result = spawnSync(process.execPath, ["--test", ...files], { cwd: path.join(__dirname, ".."), From 405006bdc9c792a98a2fceaf8ce7964416a8adbf Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:50:23 +0100 Subject: [PATCH 06/14] test: add a build smoke test `npm run test:build` cleans, runs the full build, then runs every suite against output/ instead of a temp copy of the types, plus a check that the bundle node-red picks up is all there: - editor.js and the editor/ts/json/css/html workers, with a minimum size - a locale file for each of the 13 languages - at least 50 themes, each valid JSON - node and node-red types in output/types Needs network access, like the build itself. --- package.json | 1 + test/build-output/build-output.test.js | 56 ++++++++++++++++++++++++++ test/run.js | 5 +++ 3 files changed, 62 insertions(+) create mode 100644 test/build-output/build-output.test.js diff --git a/package.json b/package.json index f5e7f7e..2498a23 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "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", "copyCheck1": "test -d output/monaco/dist", "copyCheck2": "test -d ../node-red/packages/node_modules/@node-red/editor-client/src", 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/run.js b/test/run.js index fc206bf..2e14361 100644 --- a/test/run.js +++ b/test/run.js @@ -3,6 +3,7 @@ * * 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/) */ const { spawnSync } = require("child_process"); @@ -13,6 +14,10 @@ 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"); const result = spawnSync(process.execPath, ["--test", ...files], { From 2413da300939f41f586e7783db9391038853265f Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:54:57 +0100 Subject: [PATCH 07/14] test: load the built editor in Chromium The build can succeed and still hand node-red an editor that breaks at runtime: a worker that never loads, a locale that loads but translates nothing. The type tests also lean on test/lib/editor-env.js behaving like monaco's TS worker, and nothing checked that. `npm run test:browser` (after a build) serves output/ and uses Playwright to check: - editor.js loads with no page errors, and reports the expected monaco and TypeScript versions - the ts, json and css workers report diagnostics and the html worker formats a document - every locale changes the "Format Document" label - monaco's TS worker and editor-env.js give the same hover types and diagnostic codes for the default libs, for the old default list without buffer.buffer/globals.typedarray, and for a few Setup tab modules including the broken 'test' entry editor-env.js now prints types the way the hover does (relative to the user's code), so the two can be compared exactly. pt-br is listed as known untranslated: monaco-editor has published an empty pt-br message table since at least 0.55.1. node-red still ships a working one from 0.52, so it will lose its pt-br translation with this bump. Adds playwright as a devDependency. Chromium comes from `npx playwright install chromium`. --- package-lock.json | 30 +++++ package.json | 2 + test/browser/editor.test.js | 246 ++++++++++++++++++++++++++++++++++++ test/lib/editor-env.js | 3 +- test/run.js | 2 + 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 test/browser/editor.test.js 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 2498a23..b2cef70 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "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/*", @@ -48,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/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: "

a

b

", 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/lib/editor-env.js b/test/lib/editor-env.js index 02de4bc..c603bbf 100644 --- a/test/lib/editor-env.js +++ b/test/lib/editor-env.js @@ -118,7 +118,8 @@ function probeTypes({ probes, ...opts }) { const type = checker.getTypeAtLocation(decl.initializer); results.push({ expr: probes[i], - type: checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation), + // 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), }); diff --git a/test/run.js b/test/run.js index 2e14361..bd27913 100644 --- a/test/run.js +++ b/test/run.js @@ -5,6 +5,7 @@ * 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"); @@ -19,6 +20,7 @@ if (args.includes("--output")) { 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, ".."), From c0bda3d3d4bc46e11f79e710d95a0241bb818dcc Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:55:30 +0100 Subject: [PATCH 08/14] fix(demo): load buffer.buffer.d.ts and globals.typedarray.d.ts demo.html keeps its own list of type files, and it missed the two companion files @types/node 24 split out. Without them the demo's `const buf = Buffer.from(...)` hovers as `any`, so the manual "should be of type Buffer" check fails on a good build. Now it shows Buffer. --- demo.html | 2 ++ 1 file changed, 2 insertions(+) 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", From 0e32c8f46448364521a545ba43b006962d6279b7 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:55:46 +0100 Subject: [PATCH 09/14] ci: run the tests on pushes to main and on pull requests - unit: `npm test`, no build needed - build: full build with `npm run test:build`, then `npm run test:browser` - node-red-sync: manual only (workflow_dispatch), compares with the node-red ref given as input, default master --- .github/workflows/test.yml | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6a11064 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,52 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + 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" + +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 }} From 377e78995f690eb55d3c4f8cf3be61056a4f27c2 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 09:55:57 +0100 Subject: [PATCH 10/14] docs: describe the test commands in the README --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 986809c..64458de 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,31 @@ http://localhost:8080/demo.html and you should see monaco editor with a theme set and foreign menus (try opening the context menu with a right click) +### Run the tests + +```bash +npm test +``` + +No build or network needed. It copies the types with `update-types.js` into a temp folder, then checks them the way node-red's Function node editor loads them (one `.d.ts` per module, see `test/fixtures/editor-modules.js`): + +* 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/` + +When a version bump changes a baseline on purpose, run `npm run test:update-baseline` and review the diff before committing it. + +Other suites: + +| Command | What it does | Needs | +| --- | --- | --- | +| `npm run test:build` | clean, full build, then all of the above against `output/`, plus checks on the bundle, workers, locales and themes | network | +| `npm run test:browser` | loads `output/monaco/dist` in headless Chromium: workers run, locales translate, and monaco's TS worker agrees with the tests' type checks | a build, and `npx playwright install chromium` once | +| `npm run test:node-red-sync` | compares `node-red-types/` and `test/fixtures/editor-modules.js` with node-red | `NODE_RED_DIR=../node-red` for a local checkout, or `NODE_RED_REF=` for GitHub (default `master`) | + +If node-red changes `knownModules` or `defaultServerSideTypes` in `monaco.js`, copy the change into `test/fixtures/editor-modules.js`. `npm run test:node-red-sync` tells you when they differ. + ### Add to node-red src #### Automatically From cad10f0848d8f2cfdf03261628492545f664c11a Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 10:02:50 +0100 Subject: [PATCH 11/14] docs: clarify that node-red types flow from here into node-red --- README.md | 53 ++++++++++++++---------- test/node-red-sync/node-red-sync.test.js | 9 ++-- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 64458de..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,46 +57,51 @@ 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 -Check editor works in browser... +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 run demo +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) ``` -Now go to +`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: -``` -http://localhost:8080/demo.html -``` +* 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/` -and you should see monaco editor with a theme set and foreign menus (try opening the context menu with a right click) +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. -### Run the tests +#### Compare with node-red ```bash -npm test +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) ``` -No build or network needed. It copies the types with `update-types.js` into a temp folder, then checks them the way node-red's Function node editor loads them (one `.d.ts` per module, see `test/fixtures/editor-modules.js`): +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. -* 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/` +#### Check it by eye -When a version bump changes a baseline on purpose, run `npm run test:update-baseline` and review the diff before committing it. +Check editor works in browser... -Other suites: +```bash +npm run demo +``` -| Command | What it does | Needs | -| --- | --- | --- | -| `npm run test:build` | clean, full build, then all of the above against `output/`, plus checks on the bundle, workers, locales and themes | network | -| `npm run test:browser` | loads `output/monaco/dist` in headless Chromium: workers run, locales translate, and monaco's TS worker agrees with the tests' type checks | a build, and `npx playwright install chromium` once | -| `npm run test:node-red-sync` | compares `node-red-types/` and `test/fixtures/editor-modules.js` with node-red | `NODE_RED_DIR=../node-red` for a local checkout, or `NODE_RED_REF=` for GitHub (default `master`) | +Now go to + +``` +http://localhost:8080/demo.html +``` -If node-red changes `knownModules` or `defaultServerSideTypes` in `monaco.js`, copy the change into `test/fixtures/editor-modules.js`. `npm run test:node-red-sync` tells you when they differ. +and you should see monaco editor with a theme set and foreign menus (try opening the context menu with a right click) ### Add to node-red src diff --git a/test/node-red-sync/node-red-sync.test.js b/test/node-red-sync/node-red-sync.test.js index 9f64b12..3834677 100644 --- a/test/node-red-sync/node-red-sync.test.js +++ b/test/node-red-sync/node-red-sync.test.js @@ -1,10 +1,11 @@ /** - * Is this repo in step with node-red? + * 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, but node-red also gets direct - * commits to its copies (that is how `node.linkcall()` and a stale `RED.util.linkcall` drifted). + * 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. + * 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: From f9e8190af0f795713137c3fc48057fb942ca83fa Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 10:07:39 +0100 Subject: [PATCH 12/14] ci: limit pull_request runs to main and use read-only permissions, as node-red does --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a11064..72448d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + branches: [main] workflow_dispatch: inputs: node-red-ref: @@ -11,6 +12,9 @@ on: required: false default: "master" +permissions: + contents: read + jobs: unit: runs-on: ubuntu-latest From d4548f9c37813ce204a370d046458c93bfe20dbf Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 11 Oct 2026 10:09:19 +0100 Subject: [PATCH 13/14] ci: also run on PRs into monaco-0.57.0 until it merges --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72448d3..ca132d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,8 @@ on: push: branches: [main] pull_request: - branches: [main] + # TODO: remove monaco-0.57.0 once it has merged to main (this PR is stacked on it) + branches: [main, monaco-0.57.0] workflow_dispatch: inputs: node-red-ref: From e72f6c8af5357da651af2d1bde8794383802e045 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:11:49 +0100 Subject: [PATCH 14/14] Remove explicit monaco branch test (Remove TODO) Removed reference to monaco-0.57.0 branch in pull request triggers. --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca132d7..72448d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,8 +4,7 @@ on: push: branches: [main] pull_request: - # TODO: remove monaco-0.57.0 once it has merged to main (this PR is stacked on it) - branches: [main, monaco-0.57.0] + branches: [main] workflow_dispatch: inputs: node-red-ref: