@bonsae/nrg 0.18.5 → 0.19.1

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 +2 -2
  2. package/package.json +1 -1
  3. package/server/index.cjs +86 -9
  4. package/server/resources/nrg-client.js +2020 -1987
  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 +706 -368
  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 +25 -9
  32. package/vite/index.js +648 -499
  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,203 +2042,376 @@ 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, 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 finalRuntimeSettingsFile = compiledRuntimeSettingsFilepath ? `
2214
+ const compiledRuntimeSettings = require(${JSON.stringify(
2215
+ compiledRuntimeSettingsFilepath.split(path11.sep).join("/")
2216
+ )});
2138
2217
  const settings = compiledRuntimeSettings.default || compiledRuntimeSettings;
2139
- settings.uiPort = ${this.port};
2218
+ settings.uiPort = ${port};
2140
2219
  if(!settings.userDir){
2141
- settings.userDir = "${userDir}";
2220
+ settings.userDir = ${userDirLiteral};
2142
2221
  }
2143
2222
  settings.nodesDir = settings.nodesDir || [];
2144
- if (!settings.nodesDir.includes("${outDir}")) {
2145
- settings.nodesDir.push("${outDir}");
2223
+ if (!settings.nodesDir.includes(${outDirLiteral})) {
2224
+ settings.nodesDir.push(${outDirLiteral});
2146
2225
  }
2147
2226
  if(!settings.flowFile){
2148
2227
  settings.flowFile = "flows.json";
2149
2228
  }
2229
+ // the welcome tour overlay intercepts pointer events \u2014 fatal for e2e and
2230
+ // noise for dev; explicit user settings still win
2231
+ settings.editorTheme = settings.editorTheme || {};
2232
+ if (settings.editorTheme.tours === undefined) {
2233
+ settings.editorTheme.tours = false;
2234
+ }
2150
2235
  module.exports = settings;
2151
2236
  ` : `
2152
2237
  const settings = {
2153
- uiPort: ${this.port},
2154
- userDir: "${userDir}",
2238
+ uiPort: ${port},
2239
+ userDir: ${userDirLiteral},
2155
2240
  flowFile: "flows.json",
2156
- nodesDir: ["${outDir}"],
2241
+ nodesDir: [${outDirLiteral}],
2242
+ // the welcome tour overlay intercepts pointer events \u2014 fatal for e2e
2243
+ editorTheme: { tours: false },
2157
2244
  };
2158
2245
  module.exports = settings;
2159
2246
  `;
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;
2247
+ const finalRuntimeSettingsFilepath = path11.join(
2248
+ os2.tmpdir(),
2249
+ `node-red-settings-final-${process.pid}-${port}.cjs`
2250
+ );
2251
+ fs11.writeFileSync(finalRuntimeSettingsFilepath, finalRuntimeSettingsFile);
2252
+ tempFiles.push(finalRuntimeSettingsFilepath);
2253
+ return { filepath: finalRuntimeSettingsFilepath, tempFiles };
2254
+ }
2255
+
2256
+ // src/vite/node-red-launcher/process.ts
2257
+ import { spawn } from "child_process";
2258
+ import detect from "detect-port";
2259
+ import getPort from "get-port";
2260
+ import treeKill from "tree-kill";
2261
+ var READY_MARKERS = ["Started flows", "Server now running"];
2262
+ function start(options) {
2263
+ const { entryPoint, settingsPath, args, onLine } = options;
2264
+ const child = spawn(
2265
+ process.execPath,
2266
+ [entryPoint, "-s", settingsPath, ...args],
2267
+ {
2268
+ stdio: ["ignore", "pipe", "pipe"]
2180
2269
  }
2181
- }
2182
- resolveNodeRedEntryPoint() {
2183
- this.logger.info(`Resolving ${this.nodeRedCommand} entry point...`);
2184
- const hasExplicitVersion = this.options.runtime?.version !== void 0 && this.options.runtime.version !== "latest";
2185
- if (!hasExplicitVersion) {
2186
- const localEntry = this.resolveFromLocalNodeModules();
2187
- if (localEntry) {
2188
- this.logger.info(`Resolved from local node_modules: ${localEntry}`);
2189
- return localEntry;
2270
+ );
2271
+ let isReady = false;
2272
+ let resolveReady;
2273
+ let rejectReady;
2274
+ const ready = new Promise((resolve, reject) => {
2275
+ resolveReady = resolve;
2276
+ rejectReady = reject;
2277
+ });
2278
+ const emitLine = (rawLine, source) => {
2279
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
2280
+ if (!line) return;
2281
+ onLine(line, source, isReady);
2282
+ if (source === "stdout" && READY_MARKERS.some((marker) => line.includes(marker))) {
2283
+ isReady = true;
2284
+ resolveReady();
2285
+ }
2286
+ };
2287
+ const remainders = { stdout: "", stderr: "" };
2288
+ const handleData = (data, source) => {
2289
+ const lines = (remainders[source] + data.toString()).split("\n");
2290
+ remainders[source] = lines.pop() ?? "";
2291
+ for (const line of lines) {
2292
+ emitLine(line, source);
2293
+ }
2294
+ };
2295
+ const flushRemainders = () => {
2296
+ for (const source of ["stdout", "stderr"]) {
2297
+ const rest = remainders[source];
2298
+ remainders[source] = "";
2299
+ if (rest) {
2300
+ emitLine(rest, source);
2190
2301
  }
2191
2302
  }
2192
- this.logger.info(
2193
- hasExplicitVersion ? `Using configured version (${this.options.runtime.version}), downloading via npx...` : `Not found locally, downloading via npx (this may take a while)...`
2194
- );
2195
- const resolverScript = path10.join(
2196
- os.tmpdir(),
2197
- `nrg-resolve-node-red-${process.pid}.cjs`
2198
- );
2199
- fs10.writeFileSync(
2200
- resolverScript,
2201
- `const fs = require("fs");
2202
- const path = require("path");
2203
- const isWin = process.platform === "win32";
2204
- const binName = isWin ? "node-red.cmd" : "node-red";
2205
- const dirs = process.env.PATH.split(path.delimiter);
2206
- for (const d of dirs) {
2207
- const f = path.join(d, binName);
2208
- if (fs.existsSync(f)) {
2209
- if (isWin) {
2210
- const nodeRedDir = path.resolve(d, "..", "node-red");
2211
- const pkg = JSON.parse(fs.readFileSync(path.join(nodeRedDir, "package.json"), "utf-8"));
2212
- const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin["node-red"];
2213
- process.stdout.write(path.resolve(nodeRedDir, bin));
2214
- } else {
2215
- process.stdout.write(fs.realpathSync(f));
2303
+ };
2304
+ child.stdout?.on("data", (data) => handleData(data, "stdout"));
2305
+ child.stderr?.on("data", (data) => handleData(data, "stderr"));
2306
+ child.on("error", (error) => {
2307
+ rejectReady(new NodeRedStartError(error));
2308
+ });
2309
+ child.on("exit", (code) => {
2310
+ flushRemainders();
2311
+ if (!isReady && code !== 0 && code !== null) {
2312
+ rejectReady(
2313
+ new NodeRedStartError(new Error(`Process exited with code ${code}`))
2314
+ );
2315
+ return;
2216
2316
  }
2217
- break;
2317
+ resolveReady();
2318
+ });
2319
+ return { child, ready };
2320
+ }
2321
+ function kill(pid) {
2322
+ return new Promise((resolve) => {
2323
+ treeKill(pid, "SIGKILL", () => resolve());
2324
+ });
2325
+ }
2326
+ async function stop(options) {
2327
+ const { child, pid, gracefulTimeoutMs = 1e4, logger: logger2 } = options;
2328
+ if (child.exitCode !== null || child.signalCode !== null) {
2329
+ return;
2218
2330
  }
2219
- }`
2220
- );
2221
- try {
2222
- const entryPoint = execSync(
2223
- `npx --yes -p ${this.nodeRedCommand} -c "node ${resolverScript}"`,
2224
- { encoding: "utf-8", timeout: 3e5 }
2225
- ).trim();
2226
- if (!entryPoint || !fs10.existsSync(entryPoint)) {
2227
- throw new NodeRedStartError(
2228
- new Error(
2229
- `Could not resolve node-red entry point: ${entryPoint || "(empty)"}`
2230
- )
2231
- );
2232
- }
2233
- this.logger.info(`Resolved via npx: ${entryPoint}`);
2234
- return entryPoint;
2235
- } finally {
2236
- try {
2237
- fs10.unlinkSync(resolverScript);
2238
- } catch {
2331
+ const exited = new Promise((resolve) => {
2332
+ child.once("exit", () => resolve());
2333
+ treeKill(pid, "SIGTERM", (error) => {
2334
+ if (error) {
2335
+ try {
2336
+ process.kill(pid, "SIGTERM");
2337
+ } catch {
2338
+ resolve();
2339
+ }
2239
2340
  }
2341
+ });
2342
+ });
2343
+ try {
2344
+ await withTimeout(exited, gracefulTimeoutMs);
2345
+ } catch {
2346
+ logger2.warn("Graceful shutdown timed out, force killing...");
2347
+ await kill(pid);
2348
+ }
2349
+ }
2350
+ async function acquirePort(options) {
2351
+ const { preferredPort, retryDelay = 2e3, logger: logger2 } = options;
2352
+ const available = await detect(preferredPort);
2353
+ if (available === preferredPort) {
2354
+ return preferredPort;
2355
+ }
2356
+ logger2.warn(`Port ${preferredPort} is still in use, waiting...`);
2357
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
2358
+ const retryAvailable = await detect(preferredPort);
2359
+ if (retryAvailable === preferredPort) {
2360
+ return preferredPort;
2361
+ }
2362
+ const fallbackPort = await getPort({ port: preferredPort });
2363
+ logger2.warn(
2364
+ `Port ${preferredPort} still occupied, using port ${fallbackPort}`
2365
+ );
2366
+ return fallbackPort;
2367
+ }
2368
+ async function waitForPortRelease(port, options = {}) {
2369
+ const { attempts = 10, delay = 300 } = options;
2370
+ const checkPortUsage = async () => {
2371
+ const availablePort = await detect(port);
2372
+ if (availablePort !== port) {
2373
+ throw new Error("Port still in use");
2240
2374
  }
2375
+ };
2376
+ try {
2377
+ await retry(checkPortUsage, { attempts, delay });
2378
+ return true;
2379
+ } catch {
2380
+ return false;
2381
+ }
2382
+ }
2383
+
2384
+ // src/vite/node-red-launcher/index.ts
2385
+ var NodeRedLauncher = class {
2386
+ operationQueue = Promise.resolve();
2387
+ process = null;
2388
+ unwatchExit = null;
2389
+ nodeRedEntryPoint = null;
2390
+ tempFiles = [];
2391
+ bufferedLogs = [];
2392
+ port = null;
2393
+ outDir;
2394
+ options;
2395
+ logger;
2396
+ constructor(outDir, options) {
2397
+ this.outDir = outDir;
2398
+ this.options = options;
2399
+ this.logger = new Logger({
2400
+ name: "vite-plugin-node-red",
2401
+ prefix: "node-red"
2402
+ });
2403
+ }
2404
+ get preferredPort() {
2405
+ return this.options.runtime?.port ?? 1880;
2406
+ }
2407
+ get restartDelay() {
2408
+ return this.options.restartDelay ?? 1e3;
2409
+ }
2410
+ get pid() {
2411
+ return this.process?.child.pid ?? null;
2412
+ }
2413
+ get nodeRedCommand() {
2414
+ return getNodeRedCommand(this.options.runtime?.version);
2241
2415
  }
2242
2416
  log(line) {
2243
2417
  if (line.includes("Server now running at")) {
@@ -2245,150 +2419,121 @@ for (const d of dirs) {
2245
2419
  }
2246
2420
  this.logger.raw(line);
2247
2421
  }
2248
- async start() {
2249
- const available = await detect(this.preferredPort);
2250
- if (available === this.preferredPort) {
2251
- this.port = this.preferredPort;
2422
+ handleProcessLine(line, source, ready) {
2423
+ if (!ready) {
2424
+ this.bufferedLogs.push(line);
2425
+ } else if (source === "stderr") {
2426
+ this.logger.error(line);
2252
2427
  } else {
2428
+ this.log(line);
2429
+ }
2430
+ }
2431
+ async killProcess() {
2432
+ if (!this.process) return;
2433
+ this.stopWatchingExit();
2434
+ const pid = this.process.child.pid;
2435
+ if (pid) {
2436
+ await kill(pid);
2437
+ }
2438
+ this.process = null;
2439
+ }
2440
+ watchForUnexpectedExit(managed) {
2441
+ const onExit = (code, signal) => {
2442
+ this.unwatchExit = null;
2443
+ this.process = null;
2253
2444
  this.logger.warn(
2254
- `Port ${this.preferredPort} is still in use, waiting...`
2445
+ `Node-RED exited unexpectedly (${signal ?? `code ${code}`})`
2255
2446
  );
2256
- await new Promise((resolve) => setTimeout(resolve, 2e3));
2257
- const retryAvailable = await detect(this.preferredPort);
2258
- if (retryAvailable === this.preferredPort) {
2259
- this.port = this.preferredPort;
2260
- } else {
2261
- this.logger.warn(
2262
- `Port ${this.preferredPort} still occupied, using port ${retryAvailable}`
2263
- );
2264
- this.port = await getPort({ port: this.preferredPort });
2265
- }
2266
- }
2267
- const nodeRedEntryPoint = this.resolveNodeRedEntryPoint();
2268
- const startProcess = () => {
2269
- return new Promise(async (resolve, reject) => {
2270
- try {
2271
- const settingsPath = await this.generateRuntimeSettingsFile();
2272
- const args = this.options.args ?? [];
2273
- this.bufferedLogs = [];
2274
- this.isReady = false;
2275
- this.process = spawn(
2276
- process.execPath,
2277
- [nodeRedEntryPoint, "-s", settingsPath, ...args],
2278
- {
2279
- stdio: ["ignore", "pipe", "pipe"]
2280
- }
2281
- );
2282
- this.process.stdout?.on("data", (data) => {
2283
- const lines = data.toString().split("\n").filter(Boolean);
2284
- for (const line of lines) {
2285
- if (this.isReady) {
2286
- this.log(line);
2287
- } else {
2288
- this.bufferedLogs.push(line);
2289
- }
2290
- if (line.includes("Started flows") || line.includes("Server now running")) {
2291
- this.isReady = true;
2292
- resolve(void 0);
2293
- }
2294
- }
2295
- });
2296
- this.process.stderr?.on("data", (data) => {
2297
- const lines = data.toString().split("\n").filter(Boolean);
2298
- for (const line of lines) {
2299
- if (this.isReady) {
2300
- this.logger.error(`${line}`);
2301
- } else {
2302
- this.bufferedLogs.push(line);
2303
- }
2304
- }
2305
- });
2306
- this.process.on("error", (error) => {
2307
- reject(new NodeRedStartError(error));
2308
- });
2309
- this.process.on("exit", (code) => {
2310
- if (!this.isReady && code !== 0 && code !== null) {
2311
- reject(
2312
- new NodeRedStartError(
2313
- new Error(`Process exited with code ${code}`)
2314
- )
2315
- );
2316
- }
2317
- resolve(void 0);
2318
- });
2319
- } catch (error) {
2320
- reject(new NodeRedStartError(error));
2321
- }
2322
- });
2323
2447
  };
2448
+ managed.child.once("exit", onExit);
2449
+ this.unwatchExit = () => managed.child.off("exit", onExit);
2450
+ }
2451
+ stopWatchingExit() {
2452
+ this.unwatchExit?.();
2453
+ this.unwatchExit = null;
2454
+ }
2455
+ // start/stop interleaving (e.g. SIGINT while Node-RED is booting) would
2456
+ // race on process/port state and could leak a spawned process, so all
2457
+ // lifecycle operations run strictly one at a time
2458
+ enqueue(operation) {
2459
+ const result = this.operationQueue.then(operation, operation);
2460
+ this.operationQueue = result.catch(() => {
2461
+ });
2462
+ return result;
2463
+ }
2464
+ async start() {
2465
+ return this.enqueue(() => this.doStart());
2466
+ }
2467
+ async stop(skipPortUsageCheck = false) {
2468
+ return this.enqueue(() => this.doStop(skipPortUsageCheck));
2469
+ }
2470
+ async doStart() {
2324
2471
  try {
2472
+ this.port = await acquirePort({
2473
+ preferredPort: this.preferredPort,
2474
+ logger: this.logger
2475
+ });
2476
+ if (!this.nodeRedEntryPoint || !fs12.existsSync(this.nodeRedEntryPoint)) {
2477
+ this.nodeRedEntryPoint = await resolveNodeRed({
2478
+ version: this.options.runtime?.version,
2479
+ logger: this.logger
2480
+ });
2481
+ }
2482
+ const nodeRedEntryPoint = this.nodeRedEntryPoint;
2483
+ const settings = await generateRuntimeSettings({
2484
+ outDir: this.outDir,
2485
+ port: this.port,
2486
+ settingsFilepath: this.options.runtime?.settingsFilepath,
2487
+ logger: this.logger
2488
+ });
2489
+ for (const file of settings.tempFiles) {
2490
+ if (!this.tempFiles.includes(file)) {
2491
+ this.tempFiles.push(file);
2492
+ }
2493
+ }
2494
+ const startProcess = async () => {
2495
+ await this.killProcess();
2496
+ this.bufferedLogs = [];
2497
+ this.process = start({
2498
+ entryPoint: nodeRedEntryPoint,
2499
+ settingsPath: settings.filepath,
2500
+ args: this.options.args ?? [],
2501
+ onLine: (line, source, ready) => this.handleProcessLine(line, source, ready)
2502
+ });
2503
+ await this.process.ready;
2504
+ };
2325
2505
  await retry(startProcess, { attempts: 3, delay: 100 });
2506
+ this.watchForUnexpectedExit(this.process);
2326
2507
  return this.port;
2327
2508
  } catch (error) {
2328
- if (this.process) {
2329
- const pid = this.process.pid;
2330
- if (pid) {
2331
- treeKill(pid, "SIGKILL");
2332
- }
2333
- this.process = null;
2334
- }
2335
- throw new NodeRedStartError(error);
2509
+ await this.killProcess();
2510
+ throw error instanceof NodeRedStartError ? error : new NodeRedStartError(error);
2336
2511
  }
2337
2512
  }
2338
- async stop(skipPortUsageCheck = false) {
2513
+ async doStop(skipPortUsageCheck) {
2339
2514
  if (!this.process) return;
2340
- const pid = this.process.pid;
2515
+ this.stopWatchingExit();
2516
+ const pid = this.process.child.pid;
2341
2517
  const currentPort = this.port;
2342
2518
  if (!pid) {
2343
2519
  this.process = null;
2520
+ this.port = null;
2344
2521
  return;
2345
2522
  }
2346
- const stopProcess = new Promise((resolve) => {
2347
- this.process.once("exit", () => {
2348
- this.process = null;
2349
- resolve(void 0);
2350
- });
2351
- treeKill(pid, "SIGTERM", (error) => {
2352
- if (error) {
2353
- try {
2354
- process.kill(pid, "SIGTERM");
2355
- } catch {
2356
- this.process = null;
2357
- resolve(void 0);
2358
- }
2359
- }
2360
- });
2523
+ await stop({
2524
+ child: this.process.child,
2525
+ pid,
2526
+ logger: this.logger
2361
2527
  });
2362
- try {
2363
- await withTimeout(stopProcess, 1e4);
2364
- } catch {
2365
- this.logger.warn("Graceful shutdown timed out, force killing...");
2366
- await new Promise((resolve) => {
2367
- treeKill(pid, "SIGKILL", () => {
2368
- this.process = null;
2369
- resolve(void 0);
2370
- });
2371
- });
2372
- }
2528
+ this.process = null;
2373
2529
  if (!skipPortUsageCheck && currentPort) {
2374
- const checkPortUsage = async () => {
2375
- const availablePort = await detect(currentPort);
2376
- if (availablePort !== currentPort) {
2377
- throw new Error("Port still in use");
2378
- }
2379
- };
2380
- try {
2381
- await retry(checkPortUsage, { attempts: 10, delay: 300 });
2382
- } catch {
2530
+ const released = await waitForPortRelease(currentPort);
2531
+ if (!released) {
2383
2532
  this.logger.warn(
2384
2533
  `Port ${currentPort} still in use after stop. Force killing...`
2385
2534
  );
2386
- if (pid) {
2387
- await new Promise((resolve) => {
2388
- treeKill(pid, "SIGKILL", () => resolve());
2389
- });
2390
- await new Promise((resolve) => setTimeout(resolve, 1e3));
2391
- }
2535
+ await kill(pid);
2536
+ await waitForPortRelease(currentPort);
2392
2537
  }
2393
2538
  }
2394
2539
  this.port = null;
@@ -2400,10 +2545,13 @@ for (const d of dirs) {
2400
2545
  this.bufferedLogs = [];
2401
2546
  }
2402
2547
  cleanup() {
2403
- if (this.compiledRuntimeSettingsFilepath && fs10.existsSync(this.compiledRuntimeSettingsFilepath)) {
2404
- fs10.unlinkSync(this.compiledRuntimeSettingsFilepath);
2405
- this.compiledRuntimeSettingsFilepath = null;
2548
+ for (const file of this.tempFiles) {
2549
+ try {
2550
+ fs12.unlinkSync(file);
2551
+ } catch {
2552
+ }
2406
2553
  }
2554
+ this.tempFiles = [];
2407
2555
  }
2408
2556
  };
2409
2557
 
@@ -2419,10 +2567,10 @@ var NodeRedTestEnvironment = class {
2419
2567
  options;
2420
2568
  constructor(options) {
2421
2569
  this.options = options;
2422
- this.projectDir = path11.resolve(options.projectDir ?? process.cwd());
2423
- this.outDir = path11.join(this.projectDir, "dist-e2e");
2424
- this.nodeRedDir = path11.join(this.projectDir, ".node-red");
2425
- this.installedPkgDir = path11.join(
2570
+ this.projectDir = path12.resolve(options.projectDir ?? process.cwd());
2571
+ this.outDir = path12.join(this.projectDir, "dist-e2e");
2572
+ this.nodeRedDir = path12.join(this.projectDir, ".node-red");
2573
+ this.installedPkgDir = path12.join(
2426
2574
  this.nodeRedDir,
2427
2575
  "node_modules",
2428
2576
  options.packageName
@@ -2434,15 +2582,15 @@ var NodeRedTestEnvironment = class {
2434
2582
  async setup() {
2435
2583
  this.originalCwd = process.cwd();
2436
2584
  process.chdir(this.projectDir);
2437
- if (fs11.existsSync(this.outDir)) fs11.rmSync(this.outDir, { recursive: true });
2438
- fs11.mkdirSync(this.outDir, { recursive: true });
2585
+ if (fs13.existsSync(this.outDir)) fs13.rmSync(this.outDir, { recursive: true });
2586
+ fs13.mkdirSync(this.outDir, { recursive: true });
2439
2587
  const buildContext = {
2440
2588
  outDir: this.outDir,
2441
2589
  packageName: this.options.packageName,
2442
2590
  isDev: false
2443
2591
  };
2444
2592
  const serverOpts = {
2445
- srcDir: path11.join(this.projectDir, "src/server"),
2593
+ srcDir: path12.join(this.projectDir, "src/server"),
2446
2594
  entry: "index.ts",
2447
2595
  format: "esm",
2448
2596
  bundled: [],
@@ -2452,7 +2600,7 @@ var NodeRedTestEnvironment = class {
2452
2600
  };
2453
2601
  await build(serverOpts, buildContext);
2454
2602
  const clientOpts = {
2455
- srcDir: path11.join(this.projectDir, "src/client"),
2603
+ srcDir: path12.join(this.projectDir, "src/client"),
2456
2604
  entry: "index.ts",
2457
2605
  name: this.options.clientName ?? "NodeRedNodes",
2458
2606
  format: "es",
@@ -2461,18 +2609,18 @@ var NodeRedTestEnvironment = class {
2461
2609
  ...this.options.client
2462
2610
  };
2463
2611
  await build2(clientOpts, buildContext);
2464
- fs11.mkdirSync(this.installedPkgDir, { recursive: true });
2465
- fs11.cpSync(this.outDir, this.installedPkgDir, { recursive: true });
2466
- fs11.mkdirSync(this.nodeRedDir, { recursive: true });
2467
- fs11.writeFileSync(
2468
- path11.join(this.nodeRedDir, ".config.runtime.json"),
2612
+ fs13.mkdirSync(this.installedPkgDir, { recursive: true });
2613
+ fs13.cpSync(this.outDir, this.installedPkgDir, { recursive: true });
2614
+ fs13.mkdirSync(this.nodeRedDir, { recursive: true });
2615
+ fs13.writeFileSync(
2616
+ path12.join(this.nodeRedDir, ".config.runtime.json"),
2469
2617
  JSON.stringify({ telemetryEnabled: false })
2470
2618
  );
2471
2619
  const launcherOpts = {
2472
2620
  runtime: { port: this.options.port ?? 1881 }
2473
2621
  };
2474
2622
  if (this.options.settingsFile) {
2475
- launcherOpts.runtime.settingsFilepath = path11.resolve(
2623
+ launcherOpts.runtime.settingsFilepath = path12.resolve(
2476
2624
  this.projectDir,
2477
2625
  this.options.settingsFile
2478
2626
  );
@@ -2507,9 +2655,9 @@ var NodeRedTestEnvironment = class {
2507
2655
  this.launcher.cleanup();
2508
2656
  this.launcher = null;
2509
2657
  }
2510
- if (fs11.existsSync(this.outDir)) fs11.rmSync(this.outDir, { recursive: true });
2511
- if (fs11.existsSync(this.nodeRedDir)) {
2512
- fs11.rmSync(this.nodeRedDir, { recursive: true });
2658
+ if (fs13.existsSync(this.outDir)) fs13.rmSync(this.outDir, { recursive: true });
2659
+ if (fs13.existsSync(this.nodeRedDir)) {
2660
+ fs13.rmSync(this.nodeRedDir, { recursive: true });
2513
2661
  }
2514
2662
  this.port = null;
2515
2663
  }
@@ -2524,7 +2672,7 @@ var defaultConfig = {
2524
2672
  var _env = null;
2525
2673
  async function setup(options) {
2526
2674
  const packageName = JSON.parse(
2527
- fs12.readFileSync(path12.join(process.cwd(), "package.json"), "utf-8")
2675
+ fs14.readFileSync(path13.join(process.cwd(), "package.json"), "utf-8")
2528
2676
  ).name;
2529
2677
  _env = new NodeRedTestEnvironment({
2530
2678
  packageName,
@@ -2553,8 +2701,8 @@ var NodeRedEditor = class {
2553
2701
  errors = [];
2554
2702
  screenshotDir;
2555
2703
  async screenshot(name) {
2556
- fs12.mkdirSync(this.screenshotDir, { recursive: true });
2557
- const filePath = path12.join(this.screenshotDir, `${name}.png`);
2704
+ fs14.mkdirSync(this.screenshotDir, { recursive: true });
2705
+ const filePath = path13.join(this.screenshotDir, `${name}.png`);
2558
2706
  await this.page.screenshot({ path: filePath, fullPage: true });
2559
2707
  return filePath;
2560
2708
  }
@@ -2605,31 +2753,159 @@ var NodeRedEditor = class {
2605
2753
  await this.page.waitForTimeout(500);
2606
2754
  }
2607
2755
  async clickDone() {
2608
- await this.page.evaluate(() => {
2609
- globalThis.document.getElementById("node-dialog-ok").click();
2610
- });
2611
- await this.page.waitForSelector(".red-ui-tray", {
2612
- state: "hidden",
2613
- timeout: 5e3
2614
- });
2756
+ await this.#closeTray("node-dialog-ok");
2615
2757
  }
2616
2758
  async clickCancel() {
2617
- await this.page.evaluate(() => {
2618
- globalThis.document.getElementById("node-dialog-cancel").click();
2619
- });
2620
- await this.page.waitForSelector(".red-ui-tray", {
2621
- state: "hidden",
2622
- timeout: 5e3
2623
- });
2759
+ await this.#closeTray("node-dialog-cancel");
2760
+ }
2761
+ // Waits for the tray COUNT to drop rather than for all trays to be hidden,
2762
+ // so a leftover tray from an earlier (failed) test can't poison the wait.
2763
+ async #closeTray(buttonId) {
2764
+ const before = await this.page.locator(".red-ui-tray").count();
2765
+ await this.page.evaluate((id) => {
2766
+ globalThis.document.getElementById(id).click();
2767
+ }, buttonId);
2768
+ await this.page.waitForFunction(
2769
+ (count) => document.querySelectorAll(".red-ui-tray").length < count,
2770
+ before,
2771
+ { timeout: 5e3 }
2772
+ );
2773
+ }
2774
+ /**
2775
+ * Best-effort close of every open tray — call from afterEach so a failed
2776
+ * test never leaves a tray open for the next one.
2777
+ */
2778
+ async closeAllTrays() {
2779
+ for (let attempt = 0; attempt < 5; attempt++) {
2780
+ const count = await this.page.locator(".red-ui-tray").count();
2781
+ if (count === 0) return;
2782
+ const cancel = this.page.locator("#node-config-dialog-cancel, #node-dialog-cancel").last();
2783
+ if (await cancel.count() > 0) {
2784
+ await cancel.click({ force: true }).catch(() => {
2785
+ });
2786
+ } else {
2787
+ await this.page.keyboard.press("Escape");
2788
+ }
2789
+ await this.page.waitForTimeout(400);
2790
+ }
2791
+ }
2792
+ /**
2793
+ * Closes the config-node tray (stacked above the node tray) with its own
2794
+ * Done button. The node tray underneath stays open — use clickDone() for it.
2795
+ */
2796
+ async clickConfigDone() {
2797
+ await this.page.click("#node-config-dialog-ok");
2798
+ await this.page.locator("#node-config-dialog-ok").waitFor({ state: "hidden", timeout: 5e3 });
2799
+ await this.page.waitForTimeout(300);
2800
+ }
2801
+ async clickConfigCancel() {
2802
+ await this.page.click("#node-config-dialog-cancel");
2803
+ await this.page.locator("#node-config-dialog-cancel").waitFor({ state: "hidden", timeout: 5e3 });
2804
+ await this.page.waitForTimeout(300);
2624
2805
  }
2625
2806
  field(label) {
2626
2807
  return new NodeRedField(this.page, label);
2627
2808
  }
2809
+ /**
2810
+ * Returns a JSON-safe snapshot of a node in the editor's model — use it to
2811
+ * assert values persisted after clickDone(). Functions, internals
2812
+ * (underscore-prefixed keys like `_def`), config `users` back-references,
2813
+ * and any circular references are stripped.
2814
+ */
2815
+ async getNode(nodeId) {
2816
+ return this.page.evaluate((id) => {
2817
+ const r = globalThis.RED;
2818
+ const node = r?.nodes?.node(id);
2819
+ if (!node) return null;
2820
+ const seen = /* @__PURE__ */ new WeakSet();
2821
+ return JSON.parse(
2822
+ JSON.stringify(node, (key, value) => {
2823
+ if (typeof value === "function") return void 0;
2824
+ if (key.startsWith("_") || key === "users") return void 0;
2825
+ if (typeof value === "object" && value !== null) {
2826
+ if (seen.has(value)) return void 0;
2827
+ seen.add(value);
2828
+ }
2829
+ return value;
2830
+ })
2831
+ );
2832
+ }, nodeId);
2833
+ }
2834
+ /**
2835
+ * Clicks the editor's Deploy button and waits until the workspace is clean.
2836
+ * Flows with invalid or unused nodes trigger a confirmation dialog — it is
2837
+ * confirmed automatically.
2838
+ */
2839
+ async clickDeploy() {
2840
+ await this.page.click("#red-ui-header-button-deploy");
2841
+ const confirm = this.page.locator(".red-ui-notification button.primary").first();
2842
+ const confirmationShown = await confirm.waitFor({ state: "visible", timeout: 2e3 }).then(() => true).catch(() => false);
2843
+ if (confirmationShown) await confirm.click();
2844
+ await this.page.waitForFunction(
2845
+ () => {
2846
+ const r = globalThis.RED;
2847
+ return r?.nodes?.dirty?.() === false;
2848
+ },
2849
+ { timeout: 15e3 }
2850
+ );
2851
+ }
2852
+ /** Fetches the currently deployed flow from the runtime (GET /flows). */
2853
+ async getDeployedFlow() {
2854
+ const res = await fetch(`http://localhost:${this.port}/flows`);
2855
+ if (!res.ok) {
2856
+ throw new Error(
2857
+ `Failed to fetch flows: ${res.status} ${await res.text()}`
2858
+ );
2859
+ }
2860
+ return res.json();
2861
+ }
2862
+ /** Counts the output ports rendered for a node on the canvas. */
2863
+ async getNodePortCount(nodeId) {
2864
+ const group = await this.#nodeGroup(nodeId);
2865
+ return group.locator(".red-ui-flow-port-output").count();
2866
+ }
2867
+ /** Returns the node's label text as rendered on the canvas. */
2868
+ async getNodeLabel(nodeId) {
2869
+ const group = await this.#nodeGroup(nodeId);
2870
+ const label = group.locator(".red-ui-flow-node-label").first();
2871
+ return (await label.textContent() ?? "").trim();
2872
+ }
2873
+ /** Returns the status text rendered under a node, or "" when none is set. */
2874
+ async getNodeStatus(nodeId) {
2875
+ const group = await this.#nodeGroup(nodeId);
2876
+ const status = group.locator(".red-ui-flow-node-status-label").first();
2877
+ if (await status.count() === 0) return "";
2878
+ return (await status.textContent() ?? "").trim();
2879
+ }
2880
+ async #nodeGroup(nodeId) {
2881
+ await this.page.waitForFunction(
2882
+ (id) => {
2883
+ const groups = Array.from(
2884
+ document.querySelectorAll(".red-ui-flow-node-group")
2885
+ );
2886
+ return groups.some((el) => el.__data__?.id === id);
2887
+ },
2888
+ nodeId,
2889
+ { timeout: 1e4 }
2890
+ );
2891
+ const index = await this.page.evaluate((id) => {
2892
+ const groups = Array.from(
2893
+ document.querySelectorAll(".red-ui-flow-node-group")
2894
+ );
2895
+ return groups.findIndex((el) => el.__data__?.id === id);
2896
+ }, nodeId);
2897
+ return this.page.locator(".red-ui-flow-node-group").nth(index);
2898
+ }
2899
+ /**
2900
+ * Asserts no uncaught page errors occurred, then clears the collected list
2901
+ * so each test only fails for its own errors — call it from afterEach.
2902
+ */
2628
2903
  expectNoPageErrors() {
2629
- if (this.errors.length > 0) {
2904
+ const errors = this.errors.splice(0);
2905
+ if (errors.length > 0) {
2630
2906
  throw new Error(
2631
2907
  `Page errors detected:
2632
- ${this.errors.map((e) => ` - ${e}`).join("\n")}`
2908
+ ${errors.map((e) => ` - ${e}`).join("\n")}`
2633
2909
  );
2634
2910
  }
2635
2911
  }
@@ -2641,7 +2917,7 @@ var NodeRedField = class {
2641
2917
  constructor(page, label) {
2642
2918
  this.page = page;
2643
2919
  this.label = label;
2644
- this.row = page.locator(`.form-row:has(:text("${label}"))`).first();
2920
+ this.row = page.locator(".red-ui-tray").last().locator(`.form-row:has(:text("${label}"))`).first();
2645
2921
  }
2646
2922
  row;
2647
2923
  get input() {
@@ -2716,6 +2992,18 @@ var NodeRedField = class {
2716
2992
  get addButton() {
2717
2993
  return this.row.locator("a.red-ui-button:has(i.fa-plus)");
2718
2994
  }
2995
+ /** Clicks the + button of a config field and waits for the config tray. */
2996
+ async openAddConfig() {
2997
+ await this.addButton.click();
2998
+ await this.page.locator("#node-config-dialog-ok").waitFor({ state: "visible", timeout: 1e4 });
2999
+ await this.page.waitForTimeout(500);
3000
+ }
3001
+ /** Clicks the pencil button of a config field and waits for the config tray. */
3002
+ async openEditConfig() {
3003
+ await this.editButton.click();
3004
+ await this.page.locator("#node-config-dialog-ok").waitFor({ state: "visible", timeout: 1e4 });
3005
+ await this.page.waitForTimeout(500);
3006
+ }
2719
3007
  async getSelectedOption() {
2720
3008
  return this.select.inputValue();
2721
3009
  }
@@ -2731,6 +3019,56 @@ var NodeRedField = class {
2731
3019
  get editorWrapper() {
2732
3020
  return this.row.locator(".editor-wrapper");
2733
3021
  }
3022
+ /** Reads the value of the field's code editor (Monaco, ACE fallback). */
3023
+ async getEditorValue() {
3024
+ return this.editorWrapper.evaluate((el) => {
3025
+ const w = globalThis;
3026
+ const editors = w.monaco?.editor?.getEditors?.() ?? [];
3027
+ const monacoEditor = editors.find(
3028
+ (e) => el.contains(e.getContainerDomNode())
3029
+ );
3030
+ if (monacoEditor) return monacoEditor.getValue();
3031
+ const aceEl = el.querySelector(".ace_editor");
3032
+ if (aceEl && w.ace) return w.ace.edit(aceEl).getValue();
3033
+ throw new Error("No code editor instance found in this field");
3034
+ });
3035
+ }
3036
+ /** Sets the value of the field's code editor (Monaco, ACE fallback). */
3037
+ async setEditorValue(value) {
3038
+ await this.editorWrapper.evaluate((el, newValue) => {
3039
+ const w = globalThis;
3040
+ const editors = w.monaco?.editor?.getEditors?.() ?? [];
3041
+ const monacoEditor = editors.find(
3042
+ (e) => el.contains(e.getContainerDomNode())
3043
+ );
3044
+ if (monacoEditor) {
3045
+ monacoEditor.setValue(newValue);
3046
+ return;
3047
+ }
3048
+ const aceEl = el.querySelector(".ace_editor");
3049
+ if (aceEl && w.ace) {
3050
+ w.ace.edit(aceEl).setValue(newValue, 1);
3051
+ return;
3052
+ }
3053
+ throw new Error("No code editor instance found in this field");
3054
+ }, value);
3055
+ }
3056
+ /**
3057
+ * Types into the field and returns the labels of the autocomplete
3058
+ * suggestions that appear (TypedInput types with an `autoComplete` source).
3059
+ */
3060
+ async getAutoCompleteSuggestions(prefix) {
3061
+ const input = this.row.locator("input:visible").first();
3062
+ await input.fill("", { force: true });
3063
+ await input.pressSequentially(prefix, { delay: 30 });
3064
+ const menu = this.page.locator(".red-ui-autoComplete-container:visible").first();
3065
+ await menu.waitFor({ state: "visible", timeout: 5e3 });
3066
+ const labels = await menu.locator("li").evaluateAll(
3067
+ (els) => els.map((el) => el.textContent?.trim() ?? "").filter(Boolean)
3068
+ );
3069
+ await this.page.keyboard.press("Escape");
3070
+ return labels;
3071
+ }
2734
3072
  get expandButton() {
2735
3073
  return this.row.locator(".expand-button");
2736
3074
  }