@bonsae/nrg 0.18.4 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +38 -45
  2. package/package.json +1 -1
  3. package/server/index.cjs +96 -10
  4. package/server/resources/nrg-client.js +2269 -2233
  5. package/test/client/component/config.js +11 -0
  6. package/test/client/component/index.js +218 -235
  7. package/test/client/component/nrg.css +1 -0
  8. package/test/client/component/setup.js +1549 -140
  9. package/test/client/e2e/index.js +721 -367
  10. package/test/client/unit/index.js +204 -16
  11. package/test/client/unit/setup.js +209 -19
  12. package/test/server/unit/index.js +25 -4
  13. package/tsconfig/core/client.json +1 -1
  14. package/tsconfig/test/client/component.json +1 -1
  15. package/types/client.d.ts +98 -18
  16. package/types/server.d.ts +50 -12
  17. package/types/shims/brands.d.ts +32 -0
  18. package/types/shims/{form → client/form}/components/node-red-editor-input.vue.d.ts +1 -1
  19. package/types/shims/{form → client/form}/components/node-red-json-schema-form.vue.d.ts +21 -2
  20. package/types/shims/{form → client/form}/components/node-red-select-input.vue.d.ts +1 -0
  21. package/types/shims/{form → client/form}/components/node-red-typed-input.vue.d.ts +1 -0
  22. package/types/shims/client/types.d.ts +206 -0
  23. package/types/shims/components.d.ts +8 -8
  24. package/types/shims/constants.d.ts +4 -0
  25. package/types/shims/schema-options.d.ts +23 -10
  26. package/types/shims/typebox.d.ts +2 -2
  27. package/types/test-client-component.d.ts +170 -55
  28. package/types/test-client-e2e.d.ts +50 -0
  29. package/types/test-client-unit.d.ts +86 -22
  30. package/types/test-server-unit.d.ts +3 -1
  31. package/types/vite.d.ts +38 -9
  32. package/vite/index.js +733 -528
  33. /package/types/shims/{form → client/form}/components/node-red-config-input.vue.d.ts +0 -0
  34. /package/types/shims/{form → client/form}/components/node-red-input-label.vue.d.ts +0 -0
  35. /package/types/shims/{form → client/form}/components/node-red-input.vue.d.ts +0 -0
  36. /package/types/shims/{form → client/form}/components/node-red-toggle.vue.d.ts +0 -0
  37. /package/types/shims/{globals.d.ts → client/globals.d.ts} +0 -0
@@ -1,10 +1,10 @@
1
1
  // src/test/client/e2e/index.ts
2
- import fs12 from "fs";
3
- import path12 from "path";
2
+ import fs14 from "fs";
3
+ import path13 from "path";
4
4
 
5
5
  // src/test/client/e2e/environment.ts
6
- import fs11 from "fs";
7
- import path11 from "path";
6
+ import fs13 from "fs";
7
+ import path12 from "path";
8
8
 
9
9
  // src/vite/server/build.ts
10
10
  import { build as viteBuild } from "vite";
@@ -1177,10 +1177,10 @@ function generateHelpDoc(nodeClass, labels, t) {
1177
1177
  if (inputSection) lines.push(inputSection);
1178
1178
  }
1179
1179
  if (nodeClass.outputsSchema) {
1180
- const os2 = nodeClass.outputsSchema;
1181
- if (Array.isArray(os2)) {
1180
+ const os3 = nodeClass.outputsSchema;
1181
+ if (Array.isArray(os3)) {
1182
1182
  const portSections = [];
1183
- os2.forEach((schema, i) => {
1183
+ os3.forEach((schema, i) => {
1184
1184
  const title = `${t.sections.port} ${i + 1}`;
1185
1185
  const portPropLabels = labels.outputs?.[i];
1186
1186
  const section = generateSchemaSection({
@@ -1199,9 +1199,9 @@ function generateHelpDoc(nodeClass, labels, t) {
1199
1199
  ${portSections.join("\n")}`
1200
1200
  );
1201
1201
  }
1202
- } else if (!("type" in os2 || "properties" in os2)) {
1202
+ } else if (!("type" in os3 || "properties" in os3)) {
1203
1203
  const portSections = [];
1204
- for (const [portName, schema] of Object.entries(os2)) {
1204
+ for (const [portName, schema] of Object.entries(os3)) {
1205
1205
  const portPropLabels = labels.outputs?.[portName];
1206
1206
  const section = generateSchemaSection({
1207
1207
  title: portName,
@@ -1223,7 +1223,7 @@ ${portSections.join("\n")}`
1223
1223
  const outputPropLabels = labels.outputs?.[0];
1224
1224
  const section = generateSchemaSection({
1225
1225
  title: t.sections.output,
1226
- schema: os2,
1226
+ schema: os3,
1227
1227
  t,
1228
1228
  labels: outputPropLabels,
1229
1229
  includeDefault: false
@@ -1375,103 +1375,113 @@ function localesGenerator(options) {
1375
1375
  ];
1376
1376
  const frameworkLabels = {
1377
1377
  "en-US": {
1378
- configs: { name: "Name" },
1378
+ configs: { name: "Name", returnProperty: "Return key" },
1379
1379
  toggles: {
1380
1380
  validateInput: "Validate Input",
1381
1381
  validateOutput: "Validate Output",
1382
1382
  errorPort: "Error Port",
1383
1383
  completePort: "Complete Port",
1384
- statusPort: "Status Port"
1384
+ statusPort: "Status Port",
1385
+ returnPropertyOverride: "Override return prop key"
1385
1386
  }
1386
1387
  },
1387
1388
  de: {
1388
- configs: { name: "Name" },
1389
+ configs: { name: "Name", returnProperty: "R\xFCckgabe-Schl\xFCssel" },
1389
1390
  toggles: {
1390
1391
  validateInput: "Eingabe validieren",
1391
1392
  validateOutput: "Ausgabe validieren",
1392
1393
  errorPort: "Fehler-Port",
1393
1394
  completePort: "Abschluss-Port",
1394
- statusPort: "Status-Port"
1395
+ statusPort: "Status-Port",
1396
+ returnPropertyOverride: "R\xFCckgabe-Schl\xFCssel \xFCberschreiben"
1395
1397
  }
1396
1398
  },
1397
1399
  "es-ES": {
1398
- configs: { name: "Nombre" },
1400
+ configs: { name: "Nombre", returnProperty: "Clave de retorno" },
1399
1401
  toggles: {
1400
1402
  validateInput: "Validar entrada",
1401
1403
  validateOutput: "Validar salida",
1402
1404
  errorPort: "Puerto de error",
1403
1405
  completePort: "Puerto de completado",
1404
- statusPort: "Puerto de estado"
1406
+ statusPort: "Puerto de estado",
1407
+ returnPropertyOverride: "Sobrescribir clave de retorno"
1405
1408
  }
1406
1409
  },
1407
1410
  fr: {
1408
- configs: { name: "Nom" },
1411
+ configs: { name: "Nom", returnProperty: "Cl\xE9 de retour" },
1409
1412
  toggles: {
1410
1413
  validateInput: "Valider l'entr\xE9e",
1411
1414
  validateOutput: "Valider la sortie",
1412
1415
  errorPort: "Port d'erreur",
1413
1416
  completePort: "Port de compl\xE9tion",
1414
- statusPort: "Port de statut"
1417
+ statusPort: "Port de statut",
1418
+ returnPropertyOverride: "Remplacer la cl\xE9 de retour"
1415
1419
  }
1416
1420
  },
1417
1421
  ko: {
1418
- configs: { name: "\uC774\uB984" },
1422
+ configs: { name: "\uC774\uB984", returnProperty: "\uBC18\uD658 \uD0A4" },
1419
1423
  toggles: {
1420
1424
  validateInput: "\uC785\uB825 \uAC80\uC99D",
1421
1425
  validateOutput: "\uCD9C\uB825 \uAC80\uC99D",
1422
1426
  errorPort: "\uC624\uB958 \uD3EC\uD2B8",
1423
1427
  completePort: "\uC644\uB8CC \uD3EC\uD2B8",
1424
- statusPort: "\uC0C1\uD0DC \uD3EC\uD2B8"
1428
+ statusPort: "\uC0C1\uD0DC \uD3EC\uD2B8",
1429
+ returnPropertyOverride: "\uBC18\uD658 \uD0A4 \uC7AC\uC815\uC758"
1425
1430
  }
1426
1431
  },
1427
1432
  "pt-BR": {
1428
- configs: { name: "Nome" },
1433
+ configs: { name: "Nome", returnProperty: "Chave de retorno" },
1429
1434
  toggles: {
1430
1435
  validateInput: "Validar Entrada",
1431
1436
  validateOutput: "Validar Sa\xEDda",
1432
1437
  errorPort: "Porta de Erro",
1433
1438
  completePort: "Porta de Conclus\xE3o",
1434
- statusPort: "Porta de Status"
1439
+ statusPort: "Porta de Status",
1440
+ returnPropertyOverride: "Sobrescrever chave de retorno"
1435
1441
  }
1436
1442
  },
1437
1443
  ru: {
1438
- configs: { name: "\u0418\u043C\u044F" },
1444
+ configs: { name: "\u0418\u043C\u044F", returnProperty: "\u041A\u043B\u044E\u0447 \u0432\u043E\u0437\u0432\u0440\u0430\u0442\u0430" },
1439
1445
  toggles: {
1440
1446
  validateInput: "\u041F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0432\u0445\u043E\u0434",
1441
1447
  validateOutput: "\u041F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0432\u044B\u0445\u043E\u0434",
1442
1448
  errorPort: "\u041F\u043E\u0440\u0442 \u043E\u0448\u0438\u0431\u043A\u0438",
1443
1449
  completePort: "\u041F\u043E\u0440\u0442 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u0438\u044F",
1444
- statusPort: "\u041F\u043E\u0440\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u0430"
1450
+ statusPort: "\u041F\u043E\u0440\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u0430",
1451
+ returnPropertyOverride: "\u041F\u0435\u0440\u0435\u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C \u043A\u043B\u044E\u0447 \u0432\u043E\u0437\u0432\u0440\u0430\u0442\u0430"
1445
1452
  }
1446
1453
  },
1447
1454
  ja: {
1448
- configs: { name: "\u540D\u524D" },
1455
+ configs: { name: "\u540D\u524D", returnProperty: "\u623B\u308A\u30AD\u30FC" },
1449
1456
  toggles: {
1450
1457
  validateInput: "\u5165\u529B\u691C\u8A3C",
1451
1458
  validateOutput: "\u51FA\u529B\u691C\u8A3C",
1452
1459
  errorPort: "\u30A8\u30E9\u30FC\u30DD\u30FC\u30C8",
1453
1460
  completePort: "\u5B8C\u4E86\u30DD\u30FC\u30C8",
1454
- statusPort: "\u30B9\u30C6\u30FC\u30BF\u30B9\u30DD\u30FC\u30C8"
1461
+ statusPort: "\u30B9\u30C6\u30FC\u30BF\u30B9\u30DD\u30FC\u30C8",
1462
+ returnPropertyOverride: "\u623B\u308A\u30AD\u30FC\u3092\u4E0A\u66F8\u304D"
1455
1463
  }
1456
1464
  },
1457
1465
  "zh-CN": {
1458
- configs: { name: "\u540D\u79F0" },
1466
+ configs: { name: "\u540D\u79F0", returnProperty: "\u8FD4\u56DE\u952E" },
1459
1467
  toggles: {
1460
1468
  validateInput: "\u9A8C\u8BC1\u8F93\u5165",
1461
1469
  validateOutput: "\u9A8C\u8BC1\u8F93\u51FA",
1462
1470
  errorPort: "\u9519\u8BEF\u7AEF\u53E3",
1463
1471
  completePort: "\u5B8C\u6210\u7AEF\u53E3",
1464
- statusPort: "\u72B6\u6001\u7AEF\u53E3"
1472
+ statusPort: "\u72B6\u6001\u7AEF\u53E3",
1473
+ returnPropertyOverride: "\u8986\u76D6\u8FD4\u56DE\u952E"
1465
1474
  }
1466
1475
  },
1467
1476
  "zh-TW": {
1468
- configs: { name: "\u540D\u7A31" },
1477
+ configs: { name: "\u540D\u7A31", returnProperty: "\u8FD4\u56DE\u9375" },
1469
1478
  toggles: {
1470
1479
  validateInput: "\u9A57\u8B49\u8F38\u5165",
1471
1480
  validateOutput: "\u9A57\u8B49\u8F38\u51FA",
1472
1481
  errorPort: "\u932F\u8AA4\u7AEF\u53E3",
1473
1482
  completePort: "\u5B8C\u6210\u7AEF\u53E3",
1474
- statusPort: "\u72C0\u614B\u7AEF\u53E3"
1483
+ statusPort: "\u72C0\u614B\u7AEF\u53E3",
1484
+ returnPropertyOverride: "\u8986\u84CB\u8FD4\u56DE\u9375"
1475
1485
  }
1476
1486
  }
1477
1487
  };
@@ -1994,17 +2004,8 @@ async function build2(clientBuildOptions, buildContext) {
1994
2004
  }
1995
2005
  }
1996
2006
 
1997
- // src/vite/node-red-launcher.ts
1998
- import { spawn, execSync } from "child_process";
1999
- import getPort from "get-port";
2000
- import detect from "detect-port";
2001
- import { builtinModules as builtinModules2, createRequire as createRequire3 } from "module";
2002
- import treeKill from "tree-kill";
2003
- import fs10 from "fs";
2004
- import os from "os";
2005
- import path10 from "path";
2006
- import { pathToFileURL as pathToFileURL3 } from "url";
2007
- import { build as esbuild } from "esbuild";
2007
+ // src/vite/node-red-launcher/index.ts
2008
+ import fs12 from "fs";
2008
2009
 
2009
2010
  // src/vite/async-utils.ts
2010
2011
  function withTimeout(promise, ms, fallback) {
@@ -2041,200 +2042,388 @@ async function retry(fn, options = {}) {
2041
2042
  throw lastError;
2042
2043
  }
2043
2044
 
2044
- // src/vite/node-red-launcher.ts
2045
- var NodeRedLauncher = class {
2046
- compiledRuntimeSettingsFilepath = null;
2047
- process = null;
2048
- bufferedLogs = [];
2049
- isReady = false;
2050
- port = null;
2051
- outDir;
2052
- options;
2053
- logger;
2054
- constructor(outDir, options) {
2055
- this.outDir = outDir;
2056
- this.options = options;
2057
- this.logger = new Logger({
2058
- name: "vite-plugin-node-red",
2059
- prefix: "node-red"
2060
- });
2045
+ // src/vite/node-red-launcher/entry-point.ts
2046
+ import { exec } from "child_process";
2047
+ import { randomUUID } from "crypto";
2048
+ import { createRequire as createRequire3 } from "module";
2049
+ import fs10 from "fs";
2050
+ import os from "os";
2051
+ import path10 from "path";
2052
+ function getNodeRedCommand(version) {
2053
+ return version ? `node-red@${version}` : "node-red";
2054
+ }
2055
+ function resolveNodeRedFromLocalNodeModules() {
2056
+ try {
2057
+ const require_ = createRequire3(path10.join(process.cwd(), "package.json"));
2058
+ const pkgJsonPath = require_.resolve("node-red/package.json");
2059
+ const pkgDir = path10.dirname(pkgJsonPath);
2060
+ const pkg = JSON.parse(fs10.readFileSync(pkgJsonPath, "utf-8"));
2061
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["node-red"];
2062
+ if (!bin) return null;
2063
+ const entry = path10.resolve(pkgDir, bin);
2064
+ return fs10.existsSync(entry) ? entry : null;
2065
+ } catch {
2066
+ return null;
2061
2067
  }
2062
- get preferredPort() {
2063
- return this.options.runtime?.port ?? 1880;
2068
+ }
2069
+ async function resolveNodeRed(options) {
2070
+ const { version, npxTimeoutMs = 3e5, logger: logger2 } = options;
2071
+ if (version && !/^[\w.^~<>=*-]+$/.test(version)) {
2072
+ throw new NodeRedStartError(
2073
+ new Error(`Invalid node-red version "${version}"`)
2074
+ );
2064
2075
  }
2065
- get restartDelay() {
2066
- return this.options.restartDelay ?? 1e3;
2076
+ const nodeRedCommand = getNodeRedCommand(version);
2077
+ logger2.info(`Resolving ${nodeRedCommand} entry point...`);
2078
+ const hasExplicitVersion = version !== void 0 && version !== "latest";
2079
+ if (!hasExplicitVersion) {
2080
+ const localEntry = resolveNodeRedFromLocalNodeModules();
2081
+ if (localEntry) {
2082
+ logger2.info(`Resolved from local node_modules: ${localEntry}`);
2083
+ return localEntry;
2084
+ }
2067
2085
  }
2068
- get pid() {
2069
- return this.process?.pid ?? null;
2086
+ logger2.info(
2087
+ hasExplicitVersion ? `Using configured version (${version}), downloading via npx...` : `Not found locally, downloading via npx (this may take a while)...`
2088
+ );
2089
+ const resolverScript = path10.join(
2090
+ os.tmpdir(),
2091
+ `nrg-resolve-node-red-${process.pid}-${randomUUID()}.cjs`
2092
+ );
2093
+ fs10.writeFileSync(
2094
+ resolverScript,
2095
+ `const fs = require("fs");
2096
+ const path = require("path");
2097
+ const isWin = process.platform === "win32";
2098
+ const binName = isWin ? "node-red.cmd" : "node-red";
2099
+ const dirs = process.env.PATH.split(path.delimiter);
2100
+ for (const d of dirs) {
2101
+ const f = path.join(d, binName);
2102
+ if (fs.existsSync(f)) {
2103
+ if (isWin) {
2104
+ const nodeRedDir = path.resolve(d, "..", "node-red");
2105
+ const pkg = JSON.parse(fs.readFileSync(path.join(nodeRedDir, "package.json"), "utf-8"));
2106
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin["node-red"];
2107
+ process.stdout.write(path.resolve(nodeRedDir, bin));
2108
+ } else {
2109
+ process.stdout.write(fs.realpathSync(f));
2110
+ }
2111
+ break;
2070
2112
  }
2071
- get nodeRedCommand() {
2072
- const version = this.options.runtime?.version;
2073
- if (version === "latest") {
2074
- return "node-red@latest";
2113
+ }`
2114
+ );
2115
+ try {
2116
+ const stdout = await new Promise((resolve, reject) => {
2117
+ exec(
2118
+ `npx --yes -p ${nodeRedCommand} node "${resolverScript}"`,
2119
+ { timeout: npxTimeoutMs },
2120
+ (error, stdout2) => {
2121
+ if (error) reject(error);
2122
+ else resolve(stdout2);
2123
+ }
2124
+ );
2125
+ });
2126
+ const entryPoint = stdout.trim();
2127
+ if (!entryPoint || !fs10.existsSync(entryPoint)) {
2128
+ throw new NodeRedStartError(
2129
+ new Error(
2130
+ `Could not resolve node-red entry point: ${entryPoint || "(empty)"}`
2131
+ )
2132
+ );
2075
2133
  }
2076
- if (version) {
2077
- return `node-red@${version}`;
2134
+ logger2.info(`Resolved via npx: ${entryPoint}`);
2135
+ return entryPoint;
2136
+ } finally {
2137
+ try {
2138
+ fs10.unlinkSync(resolverScript);
2139
+ } catch {
2078
2140
  }
2079
- return "node-red";
2080
2141
  }
2081
- findRuntimeSettingsFilepath() {
2082
- const runtimeSettingsFilepath = this.options.runtime?.settingsFilepath;
2083
- if (runtimeSettingsFilepath) {
2084
- const resolved2 = path10.resolve(runtimeSettingsFilepath);
2085
- if (fs10.existsSync(resolved2)) {
2086
- return resolved2;
2087
- }
2088
- this.logger.warn(`Settings file not found: ${runtimeSettingsFilepath}`);
2089
- return null;
2090
- }
2091
- const resolved = path10.resolve("node-red.settings.ts");
2092
- if (fs10.existsSync(resolved)) {
2093
- return resolved;
2142
+ }
2143
+
2144
+ // src/vite/node-red-launcher/settings.ts
2145
+ import { builtinModules as builtinModules2 } from "module";
2146
+ import fs11 from "fs";
2147
+ import os2 from "os";
2148
+ import path11 from "path";
2149
+ import { pathToFileURL as pathToFileURL3 } from "url";
2150
+ import { build as esbuild } from "esbuild";
2151
+ function findUserRuntimeSettingsFilepath(settingsFilepath, logger2) {
2152
+ if (settingsFilepath) {
2153
+ const resolved2 = path11.resolve(settingsFilepath);
2154
+ if (fs11.existsSync(resolved2)) {
2155
+ return resolved2;
2094
2156
  }
2157
+ logger2.warn(`Settings file not found: ${settingsFilepath}`);
2095
2158
  return null;
2096
2159
  }
2097
- async compileRuntimeSettingsFile(runtimeSettingsFilepath) {
2098
- const compiledRuntimeSettingsFilepath = path10.join(
2099
- os.tmpdir(),
2100
- `node-red.settings.${process.pid}.cjs`
2160
+ const resolved = path11.resolve("node-red.settings.ts");
2161
+ if (fs11.existsSync(resolved)) {
2162
+ return resolved;
2163
+ }
2164
+ return null;
2165
+ }
2166
+ async function compileRuntimeSettingsFile(runtimeSettingsFilepath, port) {
2167
+ const compiledRuntimeSettingsFilepath = path11.join(
2168
+ os2.tmpdir(),
2169
+ `node-red.settings.${process.pid}-${port}.cjs`
2170
+ );
2171
+ const nodeBuiltins2 = [
2172
+ ...builtinModules2,
2173
+ ...builtinModules2.map((m) => `node:${m}`)
2174
+ ];
2175
+ const settingsDir = path11.dirname(runtimeSettingsFilepath).split(path11.sep).join("/");
2176
+ const settingsFile = runtimeSettingsFilepath.split(path11.sep).join("/");
2177
+ await esbuild({
2178
+ entryPoints: [runtimeSettingsFilepath],
2179
+ outfile: compiledRuntimeSettingsFilepath,
2180
+ format: "cjs",
2181
+ platform: "node",
2182
+ target: "node18",
2183
+ bundle: true,
2184
+ define: {
2185
+ "import.meta.dirname": JSON.stringify(settingsDir),
2186
+ "import.meta.filename": JSON.stringify(settingsFile),
2187
+ "import.meta.url": JSON.stringify(pathToFileURL3(settingsFile).href)
2188
+ },
2189
+ external: [...nodeBuiltins2, "node-red", "@node-red/*"]
2190
+ });
2191
+ return compiledRuntimeSettingsFilepath;
2192
+ }
2193
+ async function generateRuntimeSettings(options) {
2194
+ const { outDir, port, settingsFilepath, httpAdminRoot, logger: logger2 } = options;
2195
+ const tempFiles = [];
2196
+ const userRuntimeSettingsFilepath = findUserRuntimeSettingsFilepath(
2197
+ settingsFilepath,
2198
+ logger2
2199
+ );
2200
+ let compiledRuntimeSettingsFilepath = null;
2201
+ if (userRuntimeSettingsFilepath) {
2202
+ compiledRuntimeSettingsFilepath = await compileRuntimeSettingsFile(
2203
+ userRuntimeSettingsFilepath,
2204
+ port
2101
2205
  );
2102
- const nodeBuiltins2 = [
2103
- ...builtinModules2,
2104
- ...builtinModules2.map((m) => `node:${m}`)
2105
- ];
2106
- const settingsDir = path10.dirname(runtimeSettingsFilepath).split(path10.sep).join("/");
2107
- const settingsFile = runtimeSettingsFilepath.split(path10.sep).join("/");
2108
- await esbuild({
2109
- entryPoints: [runtimeSettingsFilepath],
2110
- outfile: compiledRuntimeSettingsFilepath,
2111
- format: "cjs",
2112
- platform: "node",
2113
- target: "node18",
2114
- bundle: true,
2115
- define: {
2116
- "import.meta.dirname": JSON.stringify(settingsDir),
2117
- "import.meta.filename": JSON.stringify(settingsFile),
2118
- "import.meta.url": JSON.stringify(pathToFileURL3(settingsFile).href)
2119
- },
2120
- external: [...nodeBuiltins2, "node-red", "@node-red/*"]
2121
- });
2122
- this.compiledRuntimeSettingsFilepath = compiledRuntimeSettingsFilepath;
2123
- return compiledRuntimeSettingsFilepath;
2124
- }
2125
- async generateRuntimeSettingsFile() {
2126
- const userRuntimeSettingsFilepath = this.findRuntimeSettingsFilepath();
2127
- let compiledRuntimeSettingsFilepath = null;
2128
- if (userRuntimeSettingsFilepath) {
2129
- compiledRuntimeSettingsFilepath = await this.compileRuntimeSettingsFile(
2130
- userRuntimeSettingsFilepath
2131
- );
2132
- }
2133
- const outDir = path10.resolve(this.outDir).split(path10.sep).join("/");
2134
- const cwd = process.cwd().split(path10.sep).join("/");
2135
- const userDir = path10.resolve(cwd, ".node-red").split(path10.sep).join("/");
2136
- const finalRuntimeSettingsFile = compiledRuntimeSettingsFilepath ? `
2137
- const compiledRuntimeSettings = require("${compiledRuntimeSettingsFilepath.split(path10.sep).join("/")}");
2206
+ tempFiles.push(compiledRuntimeSettingsFilepath);
2207
+ }
2208
+ const normalizedOutDir = path11.resolve(outDir).split(path11.sep).join("/");
2209
+ const cwd = process.cwd().split(path11.sep).join("/");
2210
+ const userDir = path11.resolve(cwd, ".node-red").split(path11.sep).join("/");
2211
+ const userDirLiteral = JSON.stringify(userDir);
2212
+ const outDirLiteral = JSON.stringify(normalizedOutDir);
2213
+ const httpAdminRootAssignment = httpAdminRoot ? `settings.httpAdminRoot = ${JSON.stringify(httpAdminRoot)};
2214
+ ` : "";
2215
+ const httpAdminRootEntry = httpAdminRoot ? `
2216
+ httpAdminRoot: ${JSON.stringify(httpAdminRoot)},` : "";
2217
+ const finalRuntimeSettingsFile = compiledRuntimeSettingsFilepath ? `
2218
+ const compiledRuntimeSettings = require(${JSON.stringify(
2219
+ compiledRuntimeSettingsFilepath.split(path11.sep).join("/")
2220
+ )});
2138
2221
  const settings = compiledRuntimeSettings.default || compiledRuntimeSettings;
2139
- settings.uiPort = ${this.port};
2140
- if(!settings.userDir){
2141
- settings.userDir = "${userDir}";
2222
+ settings.uiPort = ${port};
2223
+ ${httpAdminRootAssignment}if(!settings.userDir){
2224
+ settings.userDir = ${userDirLiteral};
2142
2225
  }
2143
2226
  settings.nodesDir = settings.nodesDir || [];
2144
- if (!settings.nodesDir.includes("${outDir}")) {
2145
- settings.nodesDir.push("${outDir}");
2227
+ if (!settings.nodesDir.includes(${outDirLiteral})) {
2228
+ settings.nodesDir.push(${outDirLiteral});
2146
2229
  }
2147
2230
  if(!settings.flowFile){
2148
2231
  settings.flowFile = "flows.json";
2149
2232
  }
2233
+ // the welcome tour overlay intercepts pointer events \u2014 fatal for e2e and
2234
+ // noise for dev; explicit user settings still win
2235
+ settings.editorTheme = settings.editorTheme || {};
2236
+ if (settings.editorTheme.tours === undefined) {
2237
+ settings.editorTheme.tours = false;
2238
+ }
2150
2239
  module.exports = settings;
2151
2240
  ` : `
2152
2241
  const settings = {
2153
- uiPort: ${this.port},
2154
- userDir: "${userDir}",
2242
+ uiPort: ${port},
2243
+ userDir: ${userDirLiteral},
2155
2244
  flowFile: "flows.json",
2156
- nodesDir: ["${outDir}"],
2245
+ nodesDir: [${outDirLiteral}],${httpAdminRootEntry}
2246
+ // the welcome tour overlay intercepts pointer events \u2014 fatal for e2e
2247
+ editorTheme: { tours: false },
2157
2248
  };
2158
2249
  module.exports = settings;
2159
2250
  `;
2160
- const finalRuntimeSettingsFilepath = path10.join(
2161
- os.tmpdir(),
2162
- `node-red-settings-final-${process.pid}.cjs`
2163
- );
2164
- fs10.writeFileSync(finalRuntimeSettingsFilepath, finalRuntimeSettingsFile);
2165
- this.compiledRuntimeSettingsFilepath = finalRuntimeSettingsFilepath;
2166
- return finalRuntimeSettingsFilepath;
2167
- }
2168
- resolveFromLocalNodeModules() {
2169
- try {
2170
- const require_ = createRequire3(path10.join(process.cwd(), "package.json"));
2171
- const pkgJsonPath = require_.resolve("node-red/package.json");
2172
- const pkgDir = path10.dirname(pkgJsonPath);
2173
- const pkg = JSON.parse(fs10.readFileSync(pkgJsonPath, "utf-8"));
2174
- const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["node-red"];
2175
- if (!bin) return null;
2176
- const entry = path10.resolve(pkgDir, bin);
2177
- return fs10.existsSync(entry) ? entry : null;
2178
- } catch {
2179
- return null;
2251
+ const finalRuntimeSettingsFilepath = path11.join(
2252
+ os2.tmpdir(),
2253
+ `node-red-settings-final-${process.pid}-${port}.cjs`
2254
+ );
2255
+ fs11.writeFileSync(finalRuntimeSettingsFilepath, finalRuntimeSettingsFile);
2256
+ tempFiles.push(finalRuntimeSettingsFilepath);
2257
+ return { filepath: finalRuntimeSettingsFilepath, tempFiles };
2258
+ }
2259
+
2260
+ // src/vite/node-red-launcher/process.ts
2261
+ import { spawn } from "child_process";
2262
+ import detect from "detect-port";
2263
+ import getPort from "get-port";
2264
+ import treeKill from "tree-kill";
2265
+ var READY_MARKERS = ["Started flows", "Server now running"];
2266
+ function start(options) {
2267
+ const { entryPoint, settingsPath, args, onLine } = options;
2268
+ const child = spawn(
2269
+ process.execPath,
2270
+ [entryPoint, "-s", settingsPath, ...args],
2271
+ {
2272
+ stdio: ["ignore", "pipe", "pipe"]
2180
2273
  }
2181
- }
2182
- resolveNodeRedEntryPoint() {
2183
- this.logger.info(`Resolving ${this.nodeRedCommand} entry point...`);
2184
- const localEntry = this.resolveFromLocalNodeModules();
2185
- if (localEntry) {
2186
- this.logger.info(`Resolved from local node_modules: ${localEntry}`);
2187
- return localEntry;
2274
+ );
2275
+ let isReady = false;
2276
+ let resolveReady;
2277
+ let rejectReady;
2278
+ const ready = new Promise((resolve, reject) => {
2279
+ resolveReady = resolve;
2280
+ rejectReady = reject;
2281
+ });
2282
+ const emitLine = (rawLine, source) => {
2283
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
2284
+ if (!line) return;
2285
+ onLine(line, source, isReady);
2286
+ if (source === "stdout" && READY_MARKERS.some((marker) => line.includes(marker))) {
2287
+ isReady = true;
2288
+ resolveReady();
2188
2289
  }
2189
- this.logger.info(
2190
- `Not found locally, downloading via npx (this may take a while)...`
2191
- );
2192
- const resolverScript = path10.join(
2193
- os.tmpdir(),
2194
- `nrg-resolve-node-red-${process.pid}.cjs`
2195
- );
2196
- fs10.writeFileSync(
2197
- resolverScript,
2198
- `const fs = require("fs");
2199
- const path = require("path");
2200
- const isWin = process.platform === "win32";
2201
- const binName = isWin ? "node-red.cmd" : "node-red";
2202
- const dirs = process.env.PATH.split(path.delimiter);
2203
- for (const d of dirs) {
2204
- const f = path.join(d, binName);
2205
- if (fs.existsSync(f)) {
2206
- if (isWin) {
2207
- const nodeRedDir = path.resolve(d, "..", "node-red");
2208
- const pkg = JSON.parse(fs.readFileSync(path.join(nodeRedDir, "package.json"), "utf-8"));
2209
- const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin["node-red"];
2210
- process.stdout.write(path.resolve(nodeRedDir, bin));
2211
- } else {
2212
- process.stdout.write(fs.realpathSync(f));
2290
+ };
2291
+ const remainders = { stdout: "", stderr: "" };
2292
+ const handleData = (data, source) => {
2293
+ const lines = (remainders[source] + data.toString()).split("\n");
2294
+ remainders[source] = lines.pop() ?? "";
2295
+ for (const line of lines) {
2296
+ emitLine(line, source);
2213
2297
  }
2214
- break;
2215
- }
2216
- }`
2217
- );
2218
- try {
2219
- const entryPoint = execSync(
2220
- `npx --yes -p ${this.nodeRedCommand} -c "node ${resolverScript}"`,
2221
- { encoding: "utf-8", timeout: 3e5 }
2222
- ).trim();
2223
- if (!entryPoint || !fs10.existsSync(entryPoint)) {
2224
- throw new NodeRedStartError(
2225
- new Error(
2226
- `Could not resolve node-red entry point: ${entryPoint || "(empty)"}`
2227
- )
2228
- );
2298
+ };
2299
+ const flushRemainders = () => {
2300
+ for (const source of ["stdout", "stderr"]) {
2301
+ const rest = remainders[source];
2302
+ remainders[source] = "";
2303
+ if (rest) {
2304
+ emitLine(rest, source);
2229
2305
  }
2230
- this.logger.info(`Resolved via npx: ${entryPoint}`);
2231
- return entryPoint;
2232
- } finally {
2233
- try {
2234
- fs10.unlinkSync(resolverScript);
2235
- } catch {
2306
+ }
2307
+ };
2308
+ child.stdout?.on("data", (data) => handleData(data, "stdout"));
2309
+ child.stderr?.on("data", (data) => handleData(data, "stderr"));
2310
+ child.on("error", (error) => {
2311
+ rejectReady(new NodeRedStartError(error));
2312
+ });
2313
+ child.on("exit", (code) => {
2314
+ flushRemainders();
2315
+ if (!isReady && code !== 0 && code !== null) {
2316
+ rejectReady(
2317
+ new NodeRedStartError(new Error(`Process exited with code ${code}`))
2318
+ );
2319
+ return;
2320
+ }
2321
+ resolveReady();
2322
+ });
2323
+ return { child, ready };
2324
+ }
2325
+ function kill(pid) {
2326
+ return new Promise((resolve) => {
2327
+ treeKill(pid, "SIGKILL", () => resolve());
2328
+ });
2329
+ }
2330
+ async function stop(options) {
2331
+ const { child, pid, gracefulTimeoutMs = 1e4, logger: logger2 } = options;
2332
+ if (child.exitCode !== null || child.signalCode !== null) {
2333
+ return;
2334
+ }
2335
+ const exited = new Promise((resolve) => {
2336
+ child.once("exit", () => resolve());
2337
+ treeKill(pid, "SIGTERM", (error) => {
2338
+ if (error) {
2339
+ try {
2340
+ process.kill(pid, "SIGTERM");
2341
+ } catch {
2342
+ resolve();
2343
+ }
2236
2344
  }
2345
+ });
2346
+ });
2347
+ try {
2348
+ await withTimeout(exited, gracefulTimeoutMs);
2349
+ } catch {
2350
+ logger2.warn("Graceful shutdown timed out, force killing...");
2351
+ await kill(pid);
2352
+ }
2353
+ }
2354
+ async function acquirePort(options) {
2355
+ const { preferredPort, retryDelay = 2e3, logger: logger2 } = options;
2356
+ const available = await detect(preferredPort);
2357
+ if (available === preferredPort) {
2358
+ return preferredPort;
2359
+ }
2360
+ logger2.warn(`Port ${preferredPort} is still in use, waiting...`);
2361
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
2362
+ const retryAvailable = await detect(preferredPort);
2363
+ if (retryAvailable === preferredPort) {
2364
+ return preferredPort;
2365
+ }
2366
+ const fallbackPort = await getPort({ port: preferredPort });
2367
+ logger2.warn(
2368
+ `Port ${preferredPort} still occupied, using port ${fallbackPort}`
2369
+ );
2370
+ return fallbackPort;
2371
+ }
2372
+ async function waitForPortRelease(port, options = {}) {
2373
+ const { attempts = 10, delay = 300 } = options;
2374
+ const checkPortUsage = async () => {
2375
+ const availablePort = await detect(port);
2376
+ if (availablePort !== port) {
2377
+ throw new Error("Port still in use");
2237
2378
  }
2379
+ };
2380
+ try {
2381
+ await retry(checkPortUsage, { attempts, delay });
2382
+ return true;
2383
+ } catch {
2384
+ return false;
2385
+ }
2386
+ }
2387
+
2388
+ // src/vite/node-red-launcher/index.ts
2389
+ var NodeRedLauncher = class {
2390
+ operationQueue = Promise.resolve();
2391
+ process = null;
2392
+ unwatchExit = null;
2393
+ nodeRedEntryPoint = null;
2394
+ tempFiles = [];
2395
+ bufferedLogs = [];
2396
+ port = null;
2397
+ outDir;
2398
+ options;
2399
+ _slug;
2400
+ logger;
2401
+ constructor(outDir, options, slug = "") {
2402
+ this.outDir = outDir;
2403
+ this.options = options;
2404
+ this._slug = slug;
2405
+ this.logger = new Logger({
2406
+ name: "vite-plugin-node-red",
2407
+ prefix: "node-red"
2408
+ });
2409
+ }
2410
+ get preferredPort() {
2411
+ return this.options.runtime?.port ?? 1880;
2412
+ }
2413
+ get slug() {
2414
+ return this._slug;
2415
+ }
2416
+ get basePath() {
2417
+ return this._slug ? `/${this._slug}/` : "/";
2418
+ }
2419
+ get restartDelay() {
2420
+ return this.options.restartDelay ?? 1e3;
2421
+ }
2422
+ get pid() {
2423
+ return this.process?.child.pid ?? null;
2424
+ }
2425
+ get nodeRedCommand() {
2426
+ return getNodeRedCommand(this.options.runtime?.version);
2238
2427
  }
2239
2428
  log(line) {
2240
2429
  if (line.includes("Server now running at")) {
@@ -2242,150 +2431,122 @@ for (const d of dirs) {
2242
2431
  }
2243
2432
  this.logger.raw(line);
2244
2433
  }
2245
- async start() {
2246
- const available = await detect(this.preferredPort);
2247
- if (available === this.preferredPort) {
2248
- this.port = this.preferredPort;
2434
+ handleProcessLine(line, source, ready) {
2435
+ if (!ready) {
2436
+ this.bufferedLogs.push(line);
2437
+ } else if (source === "stderr") {
2438
+ this.logger.error(line);
2249
2439
  } else {
2440
+ this.log(line);
2441
+ }
2442
+ }
2443
+ async killProcess() {
2444
+ if (!this.process) return;
2445
+ this.stopWatchingExit();
2446
+ const pid = this.process.child.pid;
2447
+ if (pid) {
2448
+ await kill(pid);
2449
+ }
2450
+ this.process = null;
2451
+ }
2452
+ watchForUnexpectedExit(managed) {
2453
+ const onExit = (code, signal) => {
2454
+ this.unwatchExit = null;
2455
+ this.process = null;
2250
2456
  this.logger.warn(
2251
- `Port ${this.preferredPort} is still in use, waiting...`
2457
+ `Node-RED exited unexpectedly (${signal ?? `code ${code}`})`
2252
2458
  );
2253
- await new Promise((resolve) => setTimeout(resolve, 2e3));
2254
- const retryAvailable = await detect(this.preferredPort);
2255
- if (retryAvailable === this.preferredPort) {
2256
- this.port = this.preferredPort;
2257
- } else {
2258
- this.logger.warn(
2259
- `Port ${this.preferredPort} still occupied, using port ${retryAvailable}`
2260
- );
2261
- this.port = await getPort({ port: this.preferredPort });
2262
- }
2263
- }
2264
- const nodeRedEntryPoint = this.resolveNodeRedEntryPoint();
2265
- const startProcess = () => {
2266
- return new Promise(async (resolve, reject) => {
2267
- try {
2268
- const settingsPath = await this.generateRuntimeSettingsFile();
2269
- const args = this.options.args ?? [];
2270
- this.bufferedLogs = [];
2271
- this.isReady = false;
2272
- this.process = spawn(
2273
- process.execPath,
2274
- [nodeRedEntryPoint, "-s", settingsPath, ...args],
2275
- {
2276
- stdio: ["ignore", "pipe", "pipe"]
2277
- }
2278
- );
2279
- this.process.stdout?.on("data", (data) => {
2280
- const lines = data.toString().split("\n").filter(Boolean);
2281
- for (const line of lines) {
2282
- if (this.isReady) {
2283
- this.log(line);
2284
- } else {
2285
- this.bufferedLogs.push(line);
2286
- }
2287
- if (line.includes("Started flows") || line.includes("Server now running")) {
2288
- this.isReady = true;
2289
- resolve(void 0);
2290
- }
2291
- }
2292
- });
2293
- this.process.stderr?.on("data", (data) => {
2294
- const lines = data.toString().split("\n").filter(Boolean);
2295
- for (const line of lines) {
2296
- if (this.isReady) {
2297
- this.logger.error(`${line}`);
2298
- } else {
2299
- this.bufferedLogs.push(line);
2300
- }
2301
- }
2302
- });
2303
- this.process.on("error", (error) => {
2304
- reject(new NodeRedStartError(error));
2305
- });
2306
- this.process.on("exit", (code) => {
2307
- if (!this.isReady && code !== 0 && code !== null) {
2308
- reject(
2309
- new NodeRedStartError(
2310
- new Error(`Process exited with code ${code}`)
2311
- )
2312
- );
2313
- }
2314
- resolve(void 0);
2315
- });
2316
- } catch (error) {
2317
- reject(new NodeRedStartError(error));
2318
- }
2319
- });
2320
2459
  };
2460
+ managed.child.once("exit", onExit);
2461
+ this.unwatchExit = () => managed.child.off("exit", onExit);
2462
+ }
2463
+ stopWatchingExit() {
2464
+ this.unwatchExit?.();
2465
+ this.unwatchExit = null;
2466
+ }
2467
+ // start/stop interleaving (e.g. SIGINT while Node-RED is booting) would
2468
+ // race on process/port state and could leak a spawned process, so all
2469
+ // lifecycle operations run strictly one at a time
2470
+ enqueue(operation) {
2471
+ const result = this.operationQueue.then(operation, operation);
2472
+ this.operationQueue = result.catch(() => {
2473
+ });
2474
+ return result;
2475
+ }
2476
+ async start() {
2477
+ return this.enqueue(() => this.doStart());
2478
+ }
2479
+ async stop(skipPortUsageCheck = false) {
2480
+ return this.enqueue(() => this.doStop(skipPortUsageCheck));
2481
+ }
2482
+ async doStart() {
2321
2483
  try {
2484
+ this.port = await acquirePort({
2485
+ preferredPort: this.preferredPort,
2486
+ logger: this.logger
2487
+ });
2488
+ if (!this.nodeRedEntryPoint || !fs12.existsSync(this.nodeRedEntryPoint)) {
2489
+ this.nodeRedEntryPoint = await resolveNodeRed({
2490
+ version: this.options.runtime?.version,
2491
+ logger: this.logger
2492
+ });
2493
+ }
2494
+ const nodeRedEntryPoint = this.nodeRedEntryPoint;
2495
+ const settings = await generateRuntimeSettings({
2496
+ outDir: this.outDir,
2497
+ port: this.port,
2498
+ settingsFilepath: this.options.runtime?.settingsFilepath,
2499
+ httpAdminRoot: this._slug ? this.basePath : void 0,
2500
+ logger: this.logger
2501
+ });
2502
+ for (const file of settings.tempFiles) {
2503
+ if (!this.tempFiles.includes(file)) {
2504
+ this.tempFiles.push(file);
2505
+ }
2506
+ }
2507
+ const startProcess = async () => {
2508
+ await this.killProcess();
2509
+ this.bufferedLogs = [];
2510
+ this.process = start({
2511
+ entryPoint: nodeRedEntryPoint,
2512
+ settingsPath: settings.filepath,
2513
+ args: this.options.args ?? [],
2514
+ onLine: (line, source, ready) => this.handleProcessLine(line, source, ready)
2515
+ });
2516
+ await this.process.ready;
2517
+ };
2322
2518
  await retry(startProcess, { attempts: 3, delay: 100 });
2519
+ this.watchForUnexpectedExit(this.process);
2323
2520
  return this.port;
2324
2521
  } catch (error) {
2325
- if (this.process) {
2326
- const pid = this.process.pid;
2327
- if (pid) {
2328
- treeKill(pid, "SIGKILL");
2329
- }
2330
- this.process = null;
2331
- }
2332
- throw new NodeRedStartError(error);
2522
+ await this.killProcess();
2523
+ throw error instanceof NodeRedStartError ? error : new NodeRedStartError(error);
2333
2524
  }
2334
2525
  }
2335
- async stop(skipPortUsageCheck = false) {
2526
+ async doStop(skipPortUsageCheck) {
2336
2527
  if (!this.process) return;
2337
- const pid = this.process.pid;
2528
+ this.stopWatchingExit();
2529
+ const pid = this.process.child.pid;
2338
2530
  const currentPort = this.port;
2339
2531
  if (!pid) {
2340
2532
  this.process = null;
2533
+ this.port = null;
2341
2534
  return;
2342
2535
  }
2343
- const stopProcess = new Promise((resolve) => {
2344
- this.process.once("exit", () => {
2345
- this.process = null;
2346
- resolve(void 0);
2347
- });
2348
- treeKill(pid, "SIGTERM", (error) => {
2349
- if (error) {
2350
- try {
2351
- process.kill(pid, "SIGTERM");
2352
- } catch {
2353
- this.process = null;
2354
- resolve(void 0);
2355
- }
2356
- }
2357
- });
2536
+ await stop({
2537
+ child: this.process.child,
2538
+ pid,
2539
+ logger: this.logger
2358
2540
  });
2359
- try {
2360
- await withTimeout(stopProcess, 1e4);
2361
- } catch {
2362
- this.logger.warn("Graceful shutdown timed out, force killing...");
2363
- await new Promise((resolve) => {
2364
- treeKill(pid, "SIGKILL", () => {
2365
- this.process = null;
2366
- resolve(void 0);
2367
- });
2368
- });
2369
- }
2541
+ this.process = null;
2370
2542
  if (!skipPortUsageCheck && currentPort) {
2371
- const checkPortUsage = async () => {
2372
- const availablePort = await detect(currentPort);
2373
- if (availablePort !== currentPort) {
2374
- throw new Error("Port still in use");
2375
- }
2376
- };
2377
- try {
2378
- await retry(checkPortUsage, { attempts: 10, delay: 300 });
2379
- } catch {
2543
+ const released = await waitForPortRelease(currentPort);
2544
+ if (!released) {
2380
2545
  this.logger.warn(
2381
2546
  `Port ${currentPort} still in use after stop. Force killing...`
2382
2547
  );
2383
- if (pid) {
2384
- await new Promise((resolve) => {
2385
- treeKill(pid, "SIGKILL", () => resolve());
2386
- });
2387
- await new Promise((resolve) => setTimeout(resolve, 1e3));
2388
- }
2548
+ await kill(pid);
2549
+ await waitForPortRelease(currentPort);
2389
2550
  }
2390
2551
  }
2391
2552
  this.port = null;
@@ -2397,10 +2558,13 @@ for (const d of dirs) {
2397
2558
  this.bufferedLogs = [];
2398
2559
  }
2399
2560
  cleanup() {
2400
- if (this.compiledRuntimeSettingsFilepath && fs10.existsSync(this.compiledRuntimeSettingsFilepath)) {
2401
- fs10.unlinkSync(this.compiledRuntimeSettingsFilepath);
2402
- this.compiledRuntimeSettingsFilepath = null;
2561
+ for (const file of this.tempFiles) {
2562
+ try {
2563
+ fs12.unlinkSync(file);
2564
+ } catch {
2565
+ }
2403
2566
  }
2567
+ this.tempFiles = [];
2404
2568
  }
2405
2569
  };
2406
2570
 
@@ -2416,10 +2580,10 @@ var NodeRedTestEnvironment = class {
2416
2580
  options;
2417
2581
  constructor(options) {
2418
2582
  this.options = options;
2419
- this.projectDir = path11.resolve(options.projectDir ?? process.cwd());
2420
- this.outDir = path11.join(this.projectDir, "dist-e2e");
2421
- this.nodeRedDir = path11.join(this.projectDir, ".node-red");
2422
- this.installedPkgDir = path11.join(
2583
+ this.projectDir = path12.resolve(options.projectDir ?? process.cwd());
2584
+ this.outDir = path12.join(this.projectDir, "dist-e2e");
2585
+ this.nodeRedDir = path12.join(this.projectDir, ".node-red");
2586
+ this.installedPkgDir = path12.join(
2423
2587
  this.nodeRedDir,
2424
2588
  "node_modules",
2425
2589
  options.packageName
@@ -2431,15 +2595,15 @@ var NodeRedTestEnvironment = class {
2431
2595
  async setup() {
2432
2596
  this.originalCwd = process.cwd();
2433
2597
  process.chdir(this.projectDir);
2434
- if (fs11.existsSync(this.outDir)) fs11.rmSync(this.outDir, { recursive: true });
2435
- fs11.mkdirSync(this.outDir, { recursive: true });
2598
+ if (fs13.existsSync(this.outDir)) fs13.rmSync(this.outDir, { recursive: true });
2599
+ fs13.mkdirSync(this.outDir, { recursive: true });
2436
2600
  const buildContext = {
2437
2601
  outDir: this.outDir,
2438
2602
  packageName: this.options.packageName,
2439
2603
  isDev: false
2440
2604
  };
2441
2605
  const serverOpts = {
2442
- srcDir: path11.join(this.projectDir, "src/server"),
2606
+ srcDir: path12.join(this.projectDir, "src/server"),
2443
2607
  entry: "index.ts",
2444
2608
  format: "esm",
2445
2609
  bundled: [],
@@ -2449,7 +2613,7 @@ var NodeRedTestEnvironment = class {
2449
2613
  };
2450
2614
  await build(serverOpts, buildContext);
2451
2615
  const clientOpts = {
2452
- srcDir: path11.join(this.projectDir, "src/client"),
2616
+ srcDir: path12.join(this.projectDir, "src/client"),
2453
2617
  entry: "index.ts",
2454
2618
  name: this.options.clientName ?? "NodeRedNodes",
2455
2619
  format: "es",
@@ -2458,18 +2622,18 @@ var NodeRedTestEnvironment = class {
2458
2622
  ...this.options.client
2459
2623
  };
2460
2624
  await build2(clientOpts, buildContext);
2461
- fs11.mkdirSync(this.installedPkgDir, { recursive: true });
2462
- fs11.cpSync(this.outDir, this.installedPkgDir, { recursive: true });
2463
- fs11.mkdirSync(this.nodeRedDir, { recursive: true });
2464
- fs11.writeFileSync(
2465
- path11.join(this.nodeRedDir, ".config.runtime.json"),
2625
+ fs13.mkdirSync(this.installedPkgDir, { recursive: true });
2626
+ fs13.cpSync(this.outDir, this.installedPkgDir, { recursive: true });
2627
+ fs13.mkdirSync(this.nodeRedDir, { recursive: true });
2628
+ fs13.writeFileSync(
2629
+ path12.join(this.nodeRedDir, ".config.runtime.json"),
2466
2630
  JSON.stringify({ telemetryEnabled: false })
2467
2631
  );
2468
2632
  const launcherOpts = {
2469
2633
  runtime: { port: this.options.port ?? 1881 }
2470
2634
  };
2471
2635
  if (this.options.settingsFile) {
2472
- launcherOpts.runtime.settingsFilepath = path11.resolve(
2636
+ launcherOpts.runtime.settingsFilepath = path12.resolve(
2473
2637
  this.projectDir,
2474
2638
  this.options.settingsFile
2475
2639
  );
@@ -2504,9 +2668,9 @@ var NodeRedTestEnvironment = class {
2504
2668
  this.launcher.cleanup();
2505
2669
  this.launcher = null;
2506
2670
  }
2507
- if (fs11.existsSync(this.outDir)) fs11.rmSync(this.outDir, { recursive: true });
2508
- if (fs11.existsSync(this.nodeRedDir)) {
2509
- fs11.rmSync(this.nodeRedDir, { recursive: true });
2671
+ if (fs13.existsSync(this.outDir)) fs13.rmSync(this.outDir, { recursive: true });
2672
+ if (fs13.existsSync(this.nodeRedDir)) {
2673
+ fs13.rmSync(this.nodeRedDir, { recursive: true });
2510
2674
  }
2511
2675
  this.port = null;
2512
2676
  }
@@ -2521,7 +2685,7 @@ var defaultConfig = {
2521
2685
  var _env = null;
2522
2686
  async function setup(options) {
2523
2687
  const packageName = JSON.parse(
2524
- fs12.readFileSync(path12.join(process.cwd(), "package.json"), "utf-8")
2688
+ fs14.readFileSync(path13.join(process.cwd(), "package.json"), "utf-8")
2525
2689
  ).name;
2526
2690
  _env = new NodeRedTestEnvironment({
2527
2691
  packageName,
@@ -2550,8 +2714,8 @@ var NodeRedEditor = class {
2550
2714
  errors = [];
2551
2715
  screenshotDir;
2552
2716
  async screenshot(name) {
2553
- fs12.mkdirSync(this.screenshotDir, { recursive: true });
2554
- const filePath = path12.join(this.screenshotDir, `${name}.png`);
2717
+ fs14.mkdirSync(this.screenshotDir, { recursive: true });
2718
+ const filePath = path13.join(this.screenshotDir, `${name}.png`);
2555
2719
  await this.page.screenshot({ path: filePath, fullPage: true });
2556
2720
  return filePath;
2557
2721
  }
@@ -2602,31 +2766,159 @@ var NodeRedEditor = class {
2602
2766
  await this.page.waitForTimeout(500);
2603
2767
  }
2604
2768
  async clickDone() {
2605
- await this.page.evaluate(() => {
2606
- globalThis.document.getElementById("node-dialog-ok").click();
2607
- });
2608
- await this.page.waitForSelector(".red-ui-tray", {
2609
- state: "hidden",
2610
- timeout: 5e3
2611
- });
2769
+ await this.#closeTray("node-dialog-ok");
2612
2770
  }
2613
2771
  async clickCancel() {
2614
- await this.page.evaluate(() => {
2615
- globalThis.document.getElementById("node-dialog-cancel").click();
2616
- });
2617
- await this.page.waitForSelector(".red-ui-tray", {
2618
- state: "hidden",
2619
- timeout: 5e3
2620
- });
2772
+ await this.#closeTray("node-dialog-cancel");
2773
+ }
2774
+ // Waits for the tray COUNT to drop rather than for all trays to be hidden,
2775
+ // so a leftover tray from an earlier (failed) test can't poison the wait.
2776
+ async #closeTray(buttonId) {
2777
+ const before = await this.page.locator(".red-ui-tray").count();
2778
+ await this.page.evaluate((id) => {
2779
+ globalThis.document.getElementById(id).click();
2780
+ }, buttonId);
2781
+ await this.page.waitForFunction(
2782
+ (count) => document.querySelectorAll(".red-ui-tray").length < count,
2783
+ before,
2784
+ { timeout: 5e3 }
2785
+ );
2786
+ }
2787
+ /**
2788
+ * Best-effort close of every open tray — call from afterEach so a failed
2789
+ * test never leaves a tray open for the next one.
2790
+ */
2791
+ async closeAllTrays() {
2792
+ for (let attempt = 0; attempt < 5; attempt++) {
2793
+ const count = await this.page.locator(".red-ui-tray").count();
2794
+ if (count === 0) return;
2795
+ const cancel = this.page.locator("#node-config-dialog-cancel, #node-dialog-cancel").last();
2796
+ if (await cancel.count() > 0) {
2797
+ await cancel.click({ force: true }).catch(() => {
2798
+ });
2799
+ } else {
2800
+ await this.page.keyboard.press("Escape");
2801
+ }
2802
+ await this.page.waitForTimeout(400);
2803
+ }
2804
+ }
2805
+ /**
2806
+ * Closes the config-node tray (stacked above the node tray) with its own
2807
+ * Done button. The node tray underneath stays open — use clickDone() for it.
2808
+ */
2809
+ async clickConfigDone() {
2810
+ await this.page.click("#node-config-dialog-ok");
2811
+ await this.page.locator("#node-config-dialog-ok").waitFor({ state: "hidden", timeout: 5e3 });
2812
+ await this.page.waitForTimeout(300);
2813
+ }
2814
+ async clickConfigCancel() {
2815
+ await this.page.click("#node-config-dialog-cancel");
2816
+ await this.page.locator("#node-config-dialog-cancel").waitFor({ state: "hidden", timeout: 5e3 });
2817
+ await this.page.waitForTimeout(300);
2621
2818
  }
2622
2819
  field(label) {
2623
2820
  return new NodeRedField(this.page, label);
2624
2821
  }
2822
+ /**
2823
+ * Returns a JSON-safe snapshot of a node in the editor's model — use it to
2824
+ * assert values persisted after clickDone(). Functions, internals
2825
+ * (underscore-prefixed keys like `_def`), config `users` back-references,
2826
+ * and any circular references are stripped.
2827
+ */
2828
+ async getNode(nodeId) {
2829
+ return this.page.evaluate((id) => {
2830
+ const r = globalThis.RED;
2831
+ const node = r?.nodes?.node(id);
2832
+ if (!node) return null;
2833
+ const seen = /* @__PURE__ */ new WeakSet();
2834
+ return JSON.parse(
2835
+ JSON.stringify(node, (key, value) => {
2836
+ if (typeof value === "function") return void 0;
2837
+ if (key.startsWith("_") || key === "users") return void 0;
2838
+ if (typeof value === "object" && value !== null) {
2839
+ if (seen.has(value)) return void 0;
2840
+ seen.add(value);
2841
+ }
2842
+ return value;
2843
+ })
2844
+ );
2845
+ }, nodeId);
2846
+ }
2847
+ /**
2848
+ * Clicks the editor's Deploy button and waits until the workspace is clean.
2849
+ * Flows with invalid or unused nodes trigger a confirmation dialog — it is
2850
+ * confirmed automatically.
2851
+ */
2852
+ async clickDeploy() {
2853
+ await this.page.click("#red-ui-header-button-deploy");
2854
+ const confirm = this.page.locator(".red-ui-notification button.primary").first();
2855
+ const confirmationShown = await confirm.waitFor({ state: "visible", timeout: 2e3 }).then(() => true).catch(() => false);
2856
+ if (confirmationShown) await confirm.click();
2857
+ await this.page.waitForFunction(
2858
+ () => {
2859
+ const r = globalThis.RED;
2860
+ return r?.nodes?.dirty?.() === false;
2861
+ },
2862
+ { timeout: 15e3 }
2863
+ );
2864
+ }
2865
+ /** Fetches the currently deployed flow from the runtime (GET /flows). */
2866
+ async getDeployedFlow() {
2867
+ const res = await fetch(`http://localhost:${this.port}/flows`);
2868
+ if (!res.ok) {
2869
+ throw new Error(
2870
+ `Failed to fetch flows: ${res.status} ${await res.text()}`
2871
+ );
2872
+ }
2873
+ return res.json();
2874
+ }
2875
+ /** Counts the output ports rendered for a node on the canvas. */
2876
+ async getNodePortCount(nodeId) {
2877
+ const group = await this.#nodeGroup(nodeId);
2878
+ return group.locator(".red-ui-flow-port-output").count();
2879
+ }
2880
+ /** Returns the node's label text as rendered on the canvas. */
2881
+ async getNodeLabel(nodeId) {
2882
+ const group = await this.#nodeGroup(nodeId);
2883
+ const label = group.locator(".red-ui-flow-node-label").first();
2884
+ return (await label.textContent() ?? "").trim();
2885
+ }
2886
+ /** Returns the status text rendered under a node, or "" when none is set. */
2887
+ async getNodeStatus(nodeId) {
2888
+ const group = await this.#nodeGroup(nodeId);
2889
+ const status = group.locator(".red-ui-flow-node-status-label").first();
2890
+ if (await status.count() === 0) return "";
2891
+ return (await status.textContent() ?? "").trim();
2892
+ }
2893
+ async #nodeGroup(nodeId) {
2894
+ await this.page.waitForFunction(
2895
+ (id) => {
2896
+ const groups = Array.from(
2897
+ document.querySelectorAll(".red-ui-flow-node-group")
2898
+ );
2899
+ return groups.some((el) => el.__data__?.id === id);
2900
+ },
2901
+ nodeId,
2902
+ { timeout: 1e4 }
2903
+ );
2904
+ const index = await this.page.evaluate((id) => {
2905
+ const groups = Array.from(
2906
+ document.querySelectorAll(".red-ui-flow-node-group")
2907
+ );
2908
+ return groups.findIndex((el) => el.__data__?.id === id);
2909
+ }, nodeId);
2910
+ return this.page.locator(".red-ui-flow-node-group").nth(index);
2911
+ }
2912
+ /**
2913
+ * Asserts no uncaught page errors occurred, then clears the collected list
2914
+ * so each test only fails for its own errors — call it from afterEach.
2915
+ */
2625
2916
  expectNoPageErrors() {
2626
- if (this.errors.length > 0) {
2917
+ const errors = this.errors.splice(0);
2918
+ if (errors.length > 0) {
2627
2919
  throw new Error(
2628
2920
  `Page errors detected:
2629
- ${this.errors.map((e) => ` - ${e}`).join("\n")}`
2921
+ ${errors.map((e) => ` - ${e}`).join("\n")}`
2630
2922
  );
2631
2923
  }
2632
2924
  }
@@ -2638,7 +2930,7 @@ var NodeRedField = class {
2638
2930
  constructor(page, label) {
2639
2931
  this.page = page;
2640
2932
  this.label = label;
2641
- this.row = page.locator(`.form-row:has(:text("${label}"))`).first();
2933
+ this.row = page.locator(".red-ui-tray").last().locator(`.form-row:has(:text("${label}"))`).first();
2642
2934
  }
2643
2935
  row;
2644
2936
  get input() {
@@ -2713,6 +3005,18 @@ var NodeRedField = class {
2713
3005
  get addButton() {
2714
3006
  return this.row.locator("a.red-ui-button:has(i.fa-plus)");
2715
3007
  }
3008
+ /** Clicks the + button of a config field and waits for the config tray. */
3009
+ async openAddConfig() {
3010
+ await this.addButton.click();
3011
+ await this.page.locator("#node-config-dialog-ok").waitFor({ state: "visible", timeout: 1e4 });
3012
+ await this.page.waitForTimeout(500);
3013
+ }
3014
+ /** Clicks the pencil button of a config field and waits for the config tray. */
3015
+ async openEditConfig() {
3016
+ await this.editButton.click();
3017
+ await this.page.locator("#node-config-dialog-ok").waitFor({ state: "visible", timeout: 1e4 });
3018
+ await this.page.waitForTimeout(500);
3019
+ }
2716
3020
  async getSelectedOption() {
2717
3021
  return this.select.inputValue();
2718
3022
  }
@@ -2728,6 +3032,56 @@ var NodeRedField = class {
2728
3032
  get editorWrapper() {
2729
3033
  return this.row.locator(".editor-wrapper");
2730
3034
  }
3035
+ /** Reads the value of the field's code editor (Monaco, ACE fallback). */
3036
+ async getEditorValue() {
3037
+ return this.editorWrapper.evaluate((el) => {
3038
+ const w = globalThis;
3039
+ const editors = w.monaco?.editor?.getEditors?.() ?? [];
3040
+ const monacoEditor = editors.find(
3041
+ (e) => el.contains(e.getContainerDomNode())
3042
+ );
3043
+ if (monacoEditor) return monacoEditor.getValue();
3044
+ const aceEl = el.querySelector(".ace_editor");
3045
+ if (aceEl && w.ace) return w.ace.edit(aceEl).getValue();
3046
+ throw new Error("No code editor instance found in this field");
3047
+ });
3048
+ }
3049
+ /** Sets the value of the field's code editor (Monaco, ACE fallback). */
3050
+ async setEditorValue(value) {
3051
+ await this.editorWrapper.evaluate((el, newValue) => {
3052
+ const w = globalThis;
3053
+ const editors = w.monaco?.editor?.getEditors?.() ?? [];
3054
+ const monacoEditor = editors.find(
3055
+ (e) => el.contains(e.getContainerDomNode())
3056
+ );
3057
+ if (monacoEditor) {
3058
+ monacoEditor.setValue(newValue);
3059
+ return;
3060
+ }
3061
+ const aceEl = el.querySelector(".ace_editor");
3062
+ if (aceEl && w.ace) {
3063
+ w.ace.edit(aceEl).setValue(newValue, 1);
3064
+ return;
3065
+ }
3066
+ throw new Error("No code editor instance found in this field");
3067
+ }, value);
3068
+ }
3069
+ /**
3070
+ * Types into the field and returns the labels of the autocomplete
3071
+ * suggestions that appear (TypedInput types with an `autoComplete` source).
3072
+ */
3073
+ async getAutoCompleteSuggestions(prefix) {
3074
+ const input = this.row.locator("input:visible").first();
3075
+ await input.fill("", { force: true });
3076
+ await input.pressSequentially(prefix, { delay: 30 });
3077
+ const menu = this.page.locator(".red-ui-autoComplete-container:visible").first();
3078
+ await menu.waitFor({ state: "visible", timeout: 5e3 });
3079
+ const labels = await menu.locator("li").evaluateAll(
3080
+ (els) => els.map((el) => el.textContent?.trim() ?? "").filter(Boolean)
3081
+ );
3082
+ await this.page.keyboard.press("Escape");
3083
+ return labels;
3084
+ }
2731
3085
  get expandButton() {
2732
3086
  return this.row.locator(".expand-button");
2733
3087
  }