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: "

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/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 {}, 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); + }); + }); +}); 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) {