Testing
NRG nodes have two runtime surfaces — server-side logic (Node.js) and client-side UI (browser) — each with its own test strategies. NRG ships test libraries for all of them so you can verify your nodes end-to-end without running a manual Node-RED session.
Scaffolded projects
If you created your project with @bonsae/create-nrg, the vitest configs, setup files, tsconfigs, dependencies, and folder structure described below are already in place. You can skip the setup sections and go straight to the API and examples.
Dependencies
NRG ships the test libraries themselves and bundles the Vue plugin integration (@vitejs/plugin-vue) it needs for component tests. The test runner and the DOM/browser tooling are peer dependencies you install for the test types you use:
| Package | Required for | Why it's a peer dep |
|---|---|---|
vitest | All tests | Test runner — your project controls the version and runs it via CLI |
happy-dom | Client unit tests | DOM environment for client unit tests (environment: "happy-dom") — your project provides it |
@vitest/browser-playwright | Component tests | Playwright browser provider for Vitest — imported in vitest config files |
playwright | Component tests, E2E tests | Test files import it directly (e.g., import { chromium } from "playwright") |
vitest-browser-vue | Component tests | Provides the render helper for mounting Vue components in browser tests |
@vitest/coverage-v8 | Coverage (Node.js tests) | Optional — only needed when running with --coverage |
@vitest/coverage-istanbul | Coverage (browser tests) | Optional — only needed when running with --coverage |
# required
pnpm add -D vitest
# for client unit tests (DOM environment)
pnpm add -D happy-dom
# for server integration tests (a real in-process Node-RED runtime)
pnpm add -D node-red
# for component tests
pnpm add -D @vitest/browser-playwright playwright vitest-browser-vue
# optional: coverage providers
pnpm add -D @vitest/coverage-istanbul # for browser-based tests (component, e2e)
pnpm add -D @vitest/coverage-v8 # for Node.js tests (server unit, server integration, client unit)Test Types
Server
| Type | What it tests | Speed | Library |
|---|---|---|---|
| Unit | Node lifecycle, input/output routing, config, credentials, context stores, error handling | Fast (Node.js, no browser) | @bonsae/nrg/test/server/unit |
| Integration | Deployed nodes in a real Node-RED runtime — flow wiring, NodeRef resolution, credentials, context, multi-node message passing | Medium (boots Node-RED in-process) | @bonsae/nrg/test/server/integration |
Server unit tests instantiate your node class with mocked Node-RED internals and exercise it in-process. createNode wires up the full lifecycle (registered(), created(), input handlers, close) so you test real behavior, not stubs.
Server integration tests boot a real, headless Node-RED runtime, register your node classes through the same path production uses, deploy a flow, and drive it with real messages. Use them to verify the things mocks can't: that a config node resolves through a real NodeRef, that credentials reach a deployed node, that wired nodes pass messages, and that context stores persist across a flow.
Client
| Type | What it tests | Speed | Library |
|---|---|---|---|
| Unit | Pure TypeScript logic used by client code (validation, utilities, helpers) | Fast (happy-dom) | @bonsae/nrg/test/client/unit |
| Component | Vue editor components — rendering, reactivity, user interactions, validation, RED API calls | Medium (headless browsers) | @bonsae/nrg/test/client/component |
| E2E | Full editor round-trip — form rendering, validation, TypedInput, config selectors, i18n | Slow (real Node-RED instance) | @bonsae/nrg/test/client/e2e |
Client unit tests cover standalone TypeScript modules (validation logic, format helpers, etc.) without rendering Vue components. They run in a happy-dom environment with mocked RED and $ globals.
Client component tests render individual Vue components with Vitest browser mode and mocked Node-RED globals. The node and errors returned by createNode() are reactive — mutate them directly to drive conditional rendering, schema-driven validation, and RED API calls. Monaco editors and jQuery widget visuals stay mocked — that's what E2E is for.
Client E2E tests start a real Node-RED instance with your nodes installed and drive the editor with Playwright. They test the full stack — schema-driven form generation, validation messages, TypedInput widgets, config node selectors, and locale resolution.
When to Use What
| I want to verify... | Use |
|---|---|
| Input handler transforms a message correctly | Server unit |
| Node sets status after processing | Server unit |
| Config node credentials are resolved | Server unit |
| TypedInput resolves msg/flow/global values | Server unit |
| Wired nodes pass a message end to end through a flow | Server integration |
| A real config node resolves and is used by a deployed node | Server integration |
| Credentials reach a node deployed in a real runtime | Server integration |
| A node reads or writes real flow/global context | Server integration |
| A validation utility rejects invalid input | Client unit |
| A helper function formats data correctly | Client unit |
| My Vue form renders the right fields | Client component |
A component emits update:modelValue on input | Client component |
| Changing one field reveals or hides another | Client component |
| Fixing an invalid value clears its validation error | Client component |
| A NodeRef field rejects ids of unregistered config nodes | Client component |
RED.editor.createEditor is called on mount | Client component |
| The editor form shows a validation error for empty required fields | Client E2E |
| A TypedInput dropdown offers the correct types | Client E2E |
| Config node selector shows registered config nodes | Client E2E |
| Creating a config node from the node editor works end to end | Client E2E |
| Toggling a built-in port changes the node's ports on the canvas | Client E2E |
| Translations display correctly in the editor | Client E2E |
NodeDefinition lifecycle hooks (label(), paletteLabel(), outputLabels(), button.onClick, onEditResize) need no special tooling — they are plain functions. Call them in a client unit test with a fake this:
import { defineNode } from "@bonsae/nrg/client";
const def = defineNode({
/* ... */
});
expect(def.label!.call({ name: "My Node" } as any)).toBe("My Node");Server Unit Testing
Setup
1. Install dependencies
pnpm add -D vitestNo additional dependencies needed — NRG provides the test utilities and mocks.
2. Create a tsconfig
// tests/server/unit/tsconfig.json
{
"extends": "@bonsae/nrg/tsconfig/test/server/unit.json",
"include": ["**/*.ts", "../../../src/server/**/*.ts"]
}3. Create a vitest config
// vitest.server.unit.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import { defaultConfig } from "@bonsae/nrg/test/server/unit/config";
export default mergeConfig(
defaultConfig,
defineConfig({
test: {
include: ["tests/server/unit/**/*.test.ts"],
},
}),
);The defaultConfig provides:
testTimeout: 30_000@alias pointing tosrc/in your project root- a default
includeoftests/server/unit/**/*.test.ts— passing it explicitly above keeps the config self-documenting and scoped to the unit folder, separate from the integration tier intests/server/integration
4. Add a test script
{
"scripts": {
"test:server": "vitest run --config vitest.server.unit.config.ts"
}
}API
createNode(NodeClass, options?)
Creates a fully initialized node instance with mocked RED and Node-RED internals. Calls registered() and created() automatically.
Options:
| Option | Description |
|---|---|
config | Node config object (merged with schema defaults). Config node instances can be passed directly as values and will be auto-registered. |
credentials | Credentials object |
settings | RED.settings overrides |
overrides | Low-level Node-RED node overrides (id, wires, etc.) |
Returns: Promise<{ node, RED }>
createRED(options?)
Creates a standalone mock RED runtime. Useful for testing utilities or modules that depend on the RED object without instantiating a full node.
Options:
| Option | Description |
|---|---|
settings | RED.settings overrides |
Returns: MockRED
Node Test Helpers
Every node returned by createNode has these helpers:
| Method | Description |
|---|---|
node.receive(msg) | Send a message through the node's input() handler (extra Node-RED message props beyond the input schema are allowed) |
node.close(removed?) | Trigger the closed() lifecycle hook |
node.reset() | Clear all captured sent messages, statuses, and logs |
node.sent() | All raw emissions — each is a positional array, one slot per output port (so node.sent()[i][0] is the first port of emission i). Use sent(port) / sent(name) to read one port directly. |
node.sent(port) | The per-port message for a specific output port (numeric index) — one level out of the positional array, still wrapped under the return key (output) |
node.sent(name) | The per-port message for a named output port (resolved from outputsSchema keys), still wrapped under the return key (output) |
node.statuses() | All status() calls |
node.logged(level?) | Log messages, optionally filtered by level ("info", "warn", "error") |
node.warned() | Warning messages |
node.errored() | Error messages |
node.context | Promise-based access to the node's node / flow / global context stores (get/set/keys, plus atomic increment/update) — preset values before receive, assert them after |
Examples
import { describe, it, expect } from "vitest";
import { createNode } from "@bonsae/nrg/test/server/unit";
import { defineIONode } from "@bonsae/nrg/server";
import { defineSchema, SchemaType } from "@bonsae/nrg/schema";
import MyNode from "../../../src/server/nodes/my-node";
import Splitter from "../../../src/server/nodes/splitter";
import Router from "../../../src/server/nodes/router";
import RemoteServer from "../../../src/server/nodes/remote-server";
describe("my-node", () => {
it("should apply config defaults from schema", async () => {
const { node } = await createNode(MyNode);
expect(node.config.name).toBe("my-node");
});
it("should accept custom config", async () => {
const { node } = await createNode(MyNode, {
config: { greeting: "hi", timeout: 3000 },
});
expect(node.config.greeting).toBe("hi");
expect(node.config.timeout).toBe(3000);
});
it("should process input and produce output", async () => {
const { node } = await createNode(MyNode);
await node.receive({ payload: "hello" });
// send() wraps the result under the return key (`output`) and carries the
// incoming msg's fields, so the captured msg is the input plus `output`.
expect(node.sent(0)).toEqual([
{ payload: "hello", output: { payload: "HELLO" } },
]);
expect(node.statuses()[0]).toEqual({ fill: "green", text: "ok" });
});
it("should call registered() automatically", async () => {
const { RED } = await createNode(MyNode);
expect(RED.log.info).toHaveBeenCalledWith("my-node registered");
});
it("should call created() automatically", async () => {
const { node } = await createNode(MyNode);
expect(node.logged("info")).toContain("node created");
});
it("should support close lifecycle", async () => {
const { node } = await createNode(MyNode);
await node.close();
expect(node.logged("info")).toContain("node closed");
});
it("should capture logs, warnings, and errors", async () => {
const { node } = await createNode(MyNode);
await node.receive({ payload: "test" });
expect(node.logged("info")).toContain("processing test");
expect(node.warned()).toHaveLength(0);
expect(node.errored()).toHaveLength(0);
});
it("should reset captured state between assertions", async () => {
const { node } = await createNode(MyNode);
await node.receive({ payload: "a" });
expect(node.sent()).toHaveLength(1);
node.reset();
expect(node.sent()).toHaveLength(0);
expect(node.statuses()).toHaveLength(0);
expect(node.logged()).toHaveLength(0);
await node.receive({ payload: "b" });
expect(node.sent()).toHaveLength(1);
});
});
describe("credentials", () => {
it("should pass credentials to the node", async () => {
const { node } = await createNode(MyNode, {
credentials: { apiKey: "secret-123" },
});
await node.receive({ payload: "test" });
expect(node.sent(0)).toEqual([
{ payload: "test", output: { payload: "authenticated" } },
]);
});
});
describe("settings", () => {
it("should resolve settings from RED.settings", async () => {
const { node } = await createNode(MyNode, {
settings: { myNodeTimeout: 3000 },
});
await node.receive({});
// receive({}) carries no fields, so the wrapped msg is just `output`.
expect(node.sent(0)).toEqual([{ output: { payload: 3000 } }]);
});
});
describe("TypedInput", () => {
// Given a node that resolves a TypedInput in its input handler:
//
// async input(msg) {
// const value = await this.config.target.resolve(msg);
// this.send({ payload: value });
// }
it("should resolve msg property via TypedInput", async () => {
const { node } = await createNode(MyNode, {
config: { target: { value: "payload", type: "msg" } },
});
await node.receive({ payload: "from-msg" });
expect(node.sent(0)).toEqual([
{ payload: "from-msg", output: { payload: "from-msg" } },
]);
});
it("should resolve string literal via TypedInput", async () => {
const { node } = await createNode(MyNode, {
config: { target: { value: "hello", type: "str" } },
});
await node.receive({});
expect(node.sent(0)).toEqual([{ output: { payload: "hello" } }]);
});
it("should resolve number via TypedInput", async () => {
const { node } = await createNode(MyNode, {
config: { target: { value: "42", type: "num" } },
});
await node.receive({});
expect(node.sent(0)).toEqual([{ output: { payload: 42 } }]);
});
});
describe("config node references", () => {
it("should resolve NodeRef to config node instance", async () => {
const { node: server } = await createNode(RemoteServer, {
config: { host: "localhost", port: 3000 },
overrides: { id: "server-1" },
});
const { node } = await createNode(MyNode, {
config: { server: server },
});
expect(node.config.server.config.host).toBe("localhost");
});
});
describe("multi-output nodes", () => {
it("should route messages to different ports", async () => {
const { node } = await createNode(Splitter, {
config: { threshold: 50 },
});
await node.receive({ payload: 75 });
await node.receive({ payload: 30 });
expect(node.sent(0)).toEqual([
{ payload: 75, output: { payload: 75, label: "above" } },
]);
expect(node.sent(1)).toEqual([
{ payload: 30, output: { payload: 30, label: "below" } },
]);
});
});
describe("context store", () => {
it("should persist values across triggers", async () => {
const { node } = await createNode(MyNode);
await node.receive({});
await node.receive({});
expect(node.sent(0)).toEqual([
{ output: { payload: 1 } },
{ output: { payload: 2 } },
]);
});
it("can preset and assert context directly", async () => {
const { node } = await createNode(MyNode);
// seed the flow store before driving the node...
await node.context.flow!.set("count", 10);
await node.receive({});
// ...then assert what the node read/wrote
expect(node.sent(0)).toEqual([{ output: { payload: 11 } }]);
expect(await node.context.flow!.get("count")).toBe(11);
});
});
describe("error handling", () => {
const ErrorNode = defineIONode({
type: "error-test",
inputSchema: SchemaType.Object({}),
outputsSchema: SchemaType.Object({}),
async input() {
throw new Error("something broke");
},
});
it("should reject when input throws", async () => {
const { node } = await createNode(ErrorNode);
await expect(node.receive({ payload: "bad" })).rejects.toThrow(
"something broke",
);
expect(node.sent()).toHaveLength(0);
});
});
describe("i18n", () => {
it("should resolve labels with __placeholder__ substitution", async () => {
const { node } = await createNode(MyNode);
await node.receive({});
expect(node.sent(0)).toEqual([{ output: { payload: "my-node.greeting" } }]);
});
});
describe("factory API", () => {
it("should work with defineIONode", async () => {
const FactoryNode = defineIONode({
type: "factory-node",
inputSchema: SchemaType.Object({}),
outputsSchema: SchemaType.Object({}),
input(msg) {
this.send({ payload: msg.payload.toUpperCase() });
},
});
const { node } = await createNode(FactoryNode);
await node.receive({ payload: "hello" });
expect(node.sent(0)).toEqual([
{ payload: "hello", output: { payload: "HELLO" } },
]);
});
});
describe("named output ports (sendToPort)", () => {
// Given a node with named outputsSchema:
//
// static readonly outputsSchema = {
// success: defineSchema({ payload: SchemaType.String() }),
// failure: defineSchema({ error: SchemaType.String() }),
// };
it("should route messages to named ports", async () => {
const { node } = await createNode(Router, {
config: { threshold: 50 },
});
await node.receive({ payload: 75 });
expect(node.sent("success")).toEqual([
{ payload: 75, output: { payload: "passed" } },
]);
expect(node.sent("failure")).toHaveLength(0);
});
it("should send to failure port on error condition", async () => {
const { node } = await createNode(Router, {
config: { threshold: 50 },
});
await node.receive({ payload: 10 });
expect(node.sent("failure")).toEqual([
{ payload: 10, output: { error: "below threshold" } },
]);
});
});
describe("built-in emit ports", () => {
// Built-in error, complete, and status ports are **opt-in**, not automatic.
// A node only gets them when it declares the matching boolean flags in its
// config schema and the flow author (or test) turns them on — see the
// lifecycle output ports section of the node guide:
// /guide/creating-a-node#lifecycle-output-ports.
//
// const EmitConfig = defineSchema(
// {
// errorPort: SchemaType.Boolean({ default: false }),
// completePort: SchemaType.Boolean({ default: false }),
// statusPort: SchemaType.Boolean({ default: false }),
// },
// { $id: "my-node:config" },
// );
//
// The example nodes below add `EmitConfig` to their `configSchema`, and each
// test passes `config: { errorPort: true, ... }` to `createNode` to enable the
// port it asserts on. With the flag off, `node.sent("error")` returns `[]`.
it("should emit to error port when enabled and input throws", async () => {
// ErrorNode declares EmitConfig in its configSchema (see comment above).
const { node } = await createNode(ErrorNode, {
config: { errorPort: true },
});
await expect(node.receive({ payload: "bad" })).rejects.toThrow();
expect(node.sent("error")).toHaveLength(1);
expect(node.sent("error")[0]).toMatchObject({
error: { message: "something broke" },
});
});
it("should not emit to a disabled built-in port", async () => {
// Same node, error port left off: the port simply isn't there.
const { node } = await createNode(ErrorNode, {
config: { errorPort: false },
});
await expect(node.receive({ payload: "bad" })).rejects.toThrow();
expect(node.sent("error")).toEqual([]);
});
it("should carry a thrown custom error's fields under error", async () => {
// A node (with EmitConfig in its configSchema) that throws a custom
// Error subclass:
class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
super("rate limited");
this.name = "RateLimitError";
this.retryAfterMs = retryAfterMs;
}
}
const RateLimitedNode = defineIONode({
type: "rate-limited",
configSchema: defineSchema(
{
errorPort: SchemaType.Boolean({ default: false }),
completePort: SchemaType.Boolean({ default: false }),
statusPort: SchemaType.Boolean({ default: false }),
},
{ $id: "rate-limited:config" },
),
inputSchema: SchemaType.Object({}),
outputsSchema: SchemaType.Object({}),
input() {
throw new RateLimitError(2000);
},
});
const { node } = await createNode(RateLimitedNode, {
config: { errorPort: true },
});
await expect(node.receive({ payload: "go" })).rejects.toThrow();
expect(node.sent("error")[0]).toMatchObject({
error: { name: "RateLimitError", message: "rate limited", retryAfterMs: 2000 },
});
});
it("should emit to complete port when enabled on successful processing", async () => {
const { node } = await createNode(MyNode, {
config: { completePort: true },
});
await node.receive({ payload: "hello" });
expect(node.sent("complete")).toHaveLength(1);
});
it("should ride the value returned by input() on the complete port", async () => {
// A node (with EmitConfig in its configSchema) whose input() returns a value:
const ReturningNode = defineIONode({
type: "returning",
configSchema: defineSchema(
{
errorPort: SchemaType.Boolean({ default: false }),
completePort: SchemaType.Boolean({ default: false }),
statusPort: SchemaType.Boolean({ default: false }),
},
{ $id: "returning:config" },
),
inputSchema: SchemaType.Object({}),
outputsSchema: SchemaType.Object({}),
input(msg) {
return { id: msg.payload, ok: true };
},
});
const { node } = await createNode(ReturningNode, {
config: { completePort: true },
});
await node.receive({ payload: "abc" });
expect(node.sent("complete")).toHaveLength(1);
expect(node.sent("complete")[0]).toMatchObject({
output: { id: "abc", ok: true },
});
});
it("should emit to status port when enabled and status is set", async () => {
const { node } = await createNode(MyNode, {
config: { statusPort: true },
});
await node.receive({ payload: "hello" });
expect(node.sent("status")).toHaveLength(1);
expect(node.sent("status")[0]).toMatchObject({
status: { fill: "green", text: "ok" },
});
});
});Server Integration Testing
Integration tests boot a real, headless Node-RED runtime in-process, register your node classes through the same path production uses, deploy a flow, and drive it with real messages. Where unit tests mock Node-RED, integration tests run it — so they verify the seams mocks paper over: config-node NodeRef resolution, credentials reaching a deployed node, messages crossing wires, and context stores persisting across a flow.
Node-RED uses process-wide singletons, so each test file boots its own runtime in its own forked process and files run one at a time. Start one runtime per file in beforeAll and stop it in afterAll; deploy a fresh flow per test.
Setup
1. Install dependencies
pnpm add -D vitest node-redIntegration tests embed whatever node-red your project has installed — the library never bundles it. Add node-red as a dev dependency (the same version range your nodes target).
2. Create a tsconfig
// tests/server/integration/tsconfig.json
{
"extends": "@bonsae/nrg/tsconfig/test/server/integration.json",
"include": ["**/*.ts", "../../../src/server/**/*.ts"]
}3. Create a vitest config
// vitest.server.integration.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import { defaultConfig } from "@bonsae/nrg/test/server/integration/config";
export default mergeConfig(
defaultConfig,
defineConfig({
test: {
include: ["tests/server/integration/**/*.test.ts"],
},
}),
);The defaultConfig provides:
- a default
includeoftests/server/integration/**/*.test.ts— its own folder, separate from the unit tier'stests/server/unit testTimeout: 30_000andhookTimeout: 30_000(booting a runtime takes longer than a unit test)pool: "forks"withfileParallelism: false— each file gets an isolated process and they run serially, since Node-RED is a process-wide singleton@alias pointing tosrc/in your project root
Unit and integration tests are separated by folder — tests/server/unit and tests/server/integration — so each config picks up only its own tier and a missing node-red (or the slower runtime boot) never blocks fast unit feedback.
4. Add a test script
{
"scripts": {
"test:server:integration": "vitest run --config vitest.server.integration.config.ts"
}
}Published vs. linked NRG
The integration library ships with @bonsae/nrg. If your CI installs the published package, this works out of the box. The tests register your nodes against the same NRG copy your nodes import (@bonsae/nrg/server), so the runtime's instanceof checks pass — there is no second copy to clash with.
API
startRuntime(options)
Boots a headless Node-RED runtime with the given node types registered. Returns a Runtime.
| Option | Description |
|---|---|
nodes | Node classes (IONode / ConfigNode subclasses) to register — config nodes included |
settings | Extra Node-RED settings merged over the headless defaults (e.g. raise logging.console.level to debug a test) |
Returns: Promise<Runtime>
Runtime
| Method | Description |
|---|---|
runtime.flow() | Start a fresh Flow to build, deploy, drive, and inspect |
runtime.stop() | Stop Node-RED, close the server, and remove the temp user dir |
Flow
| Method | Description |
|---|---|
flow.addNode(Cls, config?, opts?) | Add any node — regular or config. Returns a NodeRef. opts: { id?, name?, credentials? } |
flow.deploy() | Build the flow JSON and deploy it; resolves once the flow has started |
flow.clear() | Drop the built nodes and clear captured messages (reset between tests) |
Pass a config node's NodeRef directly as a config value on another node — it serializes to the referenced id and resolves to the live instance, exactly like a real NodeRef field.
NodeRef
A handle to one node in the flow. Harness methods never collide with your node's own methods — the live instance lives inside the runtime.
| Method / Property | Description |
|---|---|
ref.wire(target, port?) | Wire this node's output port (default 0) to target's input |
ref.receive(msg) | Deliver a message to this node's input |
ref.read(port?, opts?) | Consume the next un-read emission (FIFO cursor), awaiting it if not yet sent. opts.timeout defaults to 5000ms |
ref.sent(port?) | Snapshot of everything this node has emitted (optionally one port) |
ref.received(port?) | Snapshot of everything delivered to this node's input |
ref.context | Promise-based access to the node's node / flow / global context stores (get/set/keys, plus atomic increment/update) — preset values before receive, assert them after |
ref.id / ref.type | The generated node id and its type |
read() walks emissions one at a time and waits for the next one — ideal for asserting ordered output or a single async result. sent() is a synchronous snapshot of everything emitted so far — ideal for counting.
Reading output
NRG wraps every send(result) as { ...incomingMsg, output: result } — the node's result lives under output and incoming fields are preserved at the top level. So assertions read (await node.read()).output. (Under the trace context mode the prior message is also kept under input; the default carry mode does not grow that chain.)
Examples
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import {
startRuntime,
type Runtime,
} from "@bonsae/nrg/test/server/integration";
import { defineIONode, ConfigNode } from "@bonsae/nrg/server";
import { defineSchema, SchemaType } from "@bonsae/nrg/schema";
const Doubler = defineIONode({
type: "doubler",
inputSchema: SchemaType.Object({}),
outputsSchema: SchemaType.Object({}),
async input(msg) {
this.send({ doubled: (msg as { value: number }).value * 2 });
},
});
describe("doubler (integration)", () => {
let runtime: Runtime;
beforeAll(async () => {
runtime = await startRuntime({ nodes: [Doubler] });
});
afterAll(async () => {
await runtime.stop();
});
it("processes input in a real runtime", async () => {
const flow = runtime.flow();
const node = flow.addNode(Doubler, {});
await flow.deploy();
await node.receive({ value: 21 });
const out = (await node.read()) as { output: { doubled: number } };
expect(out.output.doubled).toBe(42);
expect(node.sent()).toHaveLength(1);
});
});Resolving a config node through a real NodeRef — addNode the config node, then pass its NodeRef as a config value:
class Greeting extends ConfigNode {
static override readonly type = "greeting-config";
static override readonly configSchema = defineSchema(
{ greeting: SchemaType.String({ default: "hi" }) },
{ $id: "greeting-config:config" },
);
get greeting(): string {
return (this.config as { greeting: string }).greeting;
}
}
const Greeter = defineIONode({
type: "greeter",
configSchema: defineSchema(
{ source: SchemaType.NodeRef<Greeting>("greeting-config") },
{ $id: "greeter:config" },
),
inputSchema: SchemaType.Object({}),
outputsSchema: SchemaType.Object({}),
async input(msg) {
const source = this.config.source as unknown as Greeting;
this.send({ text: `${source.greeting}, ${(msg as { who: string }).who}` });
},
});
it("resolves a config node", async () => {
const flow = runtime.flow();
const greeting = flow.addNode(Greeting, { greeting: "hello" });
const greeter = flow.addNode(Greeter, { source: greeting });
await flow.deploy();
await greeter.receive({ who: "world" });
const out = (await greeter.read()) as { output: { text: string } };
expect(out.output.text).toBe("hello, world");
});Wiring nodes together — read the message at the downstream node:
it("delivers a message across a wire", async () => {
const flow = runtime.flow();
const a = flow.addNode(Doubler, {});
const b = flow.addNode(Relay, {});
a.wire(b);
await flow.deploy();
await a.receive({ value: 5 });
const relayed = (await b.read()) as { output: { relayed: boolean } };
expect(relayed.output.relayed).toBe(true);
expect(b.received().length).toBeGreaterThanOrEqual(1);
});Credentials reach the deployed node via addNode's third argument:
it("passes credentials to the deployed node", async () => {
const flow = runtime.flow();
const node = flow.addNode(
Secured,
{},
{ credentials: { token: "secret-123" } },
);
await flow.deploy();
await node.receive({});
const out = (await node.read()) as { output: { token: string } };
expect(out.output.token).toBe("secret-123");
});Context stores — preset a value before driving the node, then assert what it stored:
it("reads and writes real flow context", async () => {
const flow = runtime.flow();
const counter = flow.addNode(Counter, {});
await flow.deploy();
// preset the context before driving the node
await counter.context.flow.set("count", 10);
await counter.receive({});
const out = (await counter.read()) as { output: { count: number } };
expect(out.output.count).toBe(11);
// assert the stored value directly
expect(await counter.context.flow.get("count")).toBe(11);
});Need to mock a network boundary (an SDK, an HTTP client)? vi.mock it at the top of the file as usual — the real config node, runtime, and wiring still run; only the outermost dependency is faked.
Client Unit Testing
Client unit tests cover pure TypeScript logic — validation functions, formatters, utility modules, etc. They run in a happy-dom environment with mocked RED and $ globals, but without rendering Vue components.
Setup
1. Install dependencies
pnpm add -D vitest happy-domhappy-dom provides the DOM environment the client unit config runs in (environment: "happy-dom").
2. Create a tsconfig
// tests/client/unit/tsconfig.json
{
"extends": "@bonsae/nrg/tsconfig/test/client/unit.json",
"include": ["**/*.ts", "../../../src/client/**/*.ts"]
}3. Create a vitest config
// vitest.client.unit.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import { defaultConfig } from "@bonsae/nrg/test/client/unit/config";
export default mergeConfig(
defaultConfig,
defineConfig({
test: {
include: ["tests/client/unit/**/*.test.ts"],
},
}),
);The defaultConfig provides:
testTimeout: 30_000environment: "happy-dom"forwindow,document, and other browser globalssetupFilespointing to the built-in setup that installsREDand$mocks onwindow@alias pointing tosrc/in your project root@bonsae/nrg/clientalias resolved to the test library (souseFormNodeimports work without a runtime bundle)- a default
includeoftests/client/unit/**/*.test.ts
4. Add a test script
{
"scripts": {
"test:client:unit": "vitest run --config vitest.client.unit.config.ts"
}
}Quick Start
import { describe, it, expect } from "vitest";
import { validateNode } from "../../../src/client/validation";
describe("validateNode", () => {
it("returns true for valid config", () => {
const schema = {
type: "object",
properties: {
name: { type: "string", minLength: 1 },
},
required: ["name"],
};
const subject = { type: "my-node", name: "test" };
expect(validateNode(subject, schema)).toBe(true);
});
it("returns errors for missing required field", () => {
const schema = {
type: "object",
properties: {
name: { type: "string", minLength: 1 },
},
required: ["name"],
};
const subject = { type: "my-node", name: "" };
const result = validateNode(subject, schema);
expect(result).not.toBe(true);
expect(result).toContain("must NOT have fewer than 1 characters");
});
});Client Component Testing
Component tests render your Vue editor components in a real browser with mocked Node-RED globals. They use Vitest browser mode so you can test form rendering, widget interactions, and RED API calls without running a full Node-RED instance.
Server/client boundary
Component tests run in a real browser. Never value-import the server runtime — and that includes your src/shared/schemas/* modules, which import defineSchema/SchemaType from @bonsae/nrg/schema (which pulls in TypeBox). Value-importing them pulls the node runtime into the browser bundle and crashes the test. Instead, pass your node's type to createNode() and let the schemas globalSetup hand the real schema to the test as serialized data.
Setup
1. Install dependencies
pnpm add -D vitestNRG ships @vitejs/plugin-vue as a direct dependency; @vitest/browser-playwright, vitest-browser-vue, and playwright are optional peer dependencies — install them as shown in Dependencies.
2. Create a tsconfig
// tests/client/component/tsconfig.json
{
"extends": "@bonsae/nrg/tsconfig/test/client/component.json",
"include": [
"**/*.ts",
"../../../src/client/**/*.ts",
"../../../src/client/**/*.vue"
]
}3. Create a vitest config
// vitest.client.component.config.ts
import { defineConfig, mergeConfig } from "vitest/config";
import { defaultConfig } from "@bonsae/nrg/test/client/component/config";
export default mergeConfig(
defaultConfig,
defineConfig({
test: {
include: ["tests/client/component/**/*.test.ts"],
},
}),
);The defaultConfig provides:
- Vue plugin (
@vitejs/plugin-vue) - Playwright browser provider with chromium, firefox, and webkit instances
testTimeout: 30_000setupFilespointing to the built-in setup that installs$andREDmocks onwindowand configures Vue i18n@alias pointing tosrc/in your project root@bonsae/nrg/clientalias resolved to the test library (souseFormNodeimports work without a runtime bundle)- a default
includeoftests/client/component/**/*.test.ts - a
globalSetupof@bonsae/nrg/test/client/component/schemas, which runs in Node, serializes your package's node schemas (the default export ofsrc/server), and provides each node'sconfigSchema/credentialsSchemato the browser tests as data — socreateNode({ type })validates against your real production schema (see Resolving schemas by node type)
The default config deliberately does not prebundle @bonsae/nrg/server: schemas reach the browser as serialized data via the globalSetup above, never by value-importing a schema (or node) module. Don't add @bonsae/nrg/server to your own optimizeDeps.
To test on a single browser only, override the browser.instances array:
export default mergeConfig(
defaultConfig,
defineConfig({
test: {
include: ["tests/client/component/**/*.test.ts"],
browser: {
instances: [{ browser: "chromium" }],
},
},
}),
);4. Add a test script
{
"scripts": {
"test:client:component": "vitest run --config vitest.client.component.config.ts"
}
}API
createNode(options?)
Creates a reactive mock Node-RED node and the provide object needed by useFormNode() components. Returns { node, errors, RED, provide } — the node and errors are wrapped in Vue reactive(), so mutating them in a test re-renders mounted components and re-runs validation, exactly like the real editor.
| Option | Description |
|---|---|
type | The node's registered type. Resolves the real configSchema/credentialsSchema for that node from the serialized-schema map the schemas globalSetup provides — validate against the production schema without importing it into the browser. |
configs | Initial config values, spread onto the node |
credentials | Initial credential values, nested under node.credentials |
configSchema | Explicit config schema (plain JSON Schema data). Overrides the type-resolved config schema; use for inline/ad-hoc schemas. |
credentialsSchema | Explicit credentials schema (plain JSON Schema data). Overrides the type-resolved credentials schema; errors are keyed node.credentials.<prop>. |
nodes | Fake config nodes resolvable via RED.nodes.node(id) — required for NodeRef field validation |
A plain object without any of these keys is shorthand for configs:
const { provide } = createNode({ name: "test", retries: 3 });
render(MyForm, { global: { provide } });The shorthand discriminator only recognizes type, configs, configSchema, credentialsSchema, and nodes. credentials is not a standalone shorthand key — passing { credentials: {...} } on its own is treated as config values. To set credentials, pass credentials alongside one of the recognized keys (for example type or configSchema).
When you pass a node type, errors is populated immediately against that node's real production schema — the same JSON the vite plugin injects into the editor — and kept in sync as the node changes. No schema import, no server runtime in the browser:
const { node, errors } = createNode({
type: "my-node",
configs: { name: "" },
credentials: { token: "" },
});
expect(errors["node.name"]).toBeDefined(); // invalid initial state
node.name = "valid";
await vi.waitFor(() => {
expect(errors["node.name"]).toBeUndefined(); // revalidated reactively
});configSchema/credentialsSchema can still be passed explicitly for inline or ad-hoc schemas — each overrides the corresponding type-resolved schema independently. They must be plain JSON Schema data; never value-import a server schema module into a browser test (see the boundary note above).
Fields declared with SchemaType.NodeRef validate that the referenced config node exists — register fakes with nodes:
const { errors } = createNode({
type: "my-node",
configs: { connection: "cfg-1" },
nodes: [{ id: "cfg-1", type: "my-config" }],
});
expect(errors["node.connection"]).toBeUndefined();When you also need the node or RED instance (e.g. to assert on node.id or spy on RED methods), destructure them:
const { node, RED, provide } = createNode({ name: "test" });
render(MyForm, { global: { provide } });
expect(RED.editor.createEditor).toHaveBeenCalled();You can override any RED method per test while keeping the default implementation for the rest:
const { RED, provide } = createNode();
RED.nodes.dirty.mockReturnValue(true);vi.restoreAllMocks() safely strips the spies. The next createNode call re-applies fresh ones, so tests stay isolated.
The mock implements the editor's RED contract with working state, reset between tests by the built-in setup:
| Namespace | Behavior |
|---|---|
RED._ | _(key) — returns the key as-is |
RED.editor | createEditor(options) returns a working mock editor — getValue/setValue, and setValue fires getSession().on("change") listeners like real ACE/Monaco. prepareConfigNodeSelect(...), validateNode(...) |
RED.tray | show(...), close() |
RED.popover | create(options) and tooltip(...) — both return chainable instances |
RED.nodes | A working registry: add/node/remove/clear, registerType/getType, eachNode/eachConfig/filterNodes, filterLinks/addLink, dirty() getter/setter, id(). createNode({ nodes }) fakes are visible to all of these |
RED.events | A functioning event bus — emit dispatches to on listeners, so tests can drive components subscribed to editor events |
RED.comms | subscribe/unsubscribe plus a test-only publish(topic, msg) to simulate runtime messages — + and # topic wildcards supported |
RED.settings | get/set/remove plus direct property access (exportable settings appear as direct properties) |
RED.notify | Returns a notification handle with update() and close() |
Drive a component that listens to runtime state:
const { RED, provide } = createNode({});
render(DeployStatus, { global: { provide } });
RED.comms.publish("nrg/deploy/job-1", { state: "done" });
await vi.waitFor(() => {
// assert the component rendered the update
});Editor instances created by components are reachable through the spy:
const instance = vi.mocked(RED.editor.createEditor).mock.results[0].value;
instance.setValue("new code"); // fires the component's change listenerResolving schemas by node type
createNode({ type }) gets its schema from a globalSetup the default config wires up: @bonsae/nrg/test/client/component/schemas. It runs in Node (where importing the server is fine), imports your package's node registry — the default export of src/server — serializes every node's configSchema/credentialsSchema to plain JSON (exactly as the vite plugin does for production), and provides them to the browser tests as data. Your test names a type; the harness hands back the real schema. Nothing server-side is ever value-imported into the browser.
If your node registry is not the default export of src/server, write your own globalSetup with provideSchemas:
// tests/client/component/schemas.ts
import { provideSchemas } from "@bonsae/nrg/test/client/component/schemas";
import registry from "../../../src/server"; // wherever your defineModule({ nodes }) lives
export default provideSchemas(registry);then point the config at it instead of the convention default:
test: {
globalSetup: ["./tests/client/component/schemas.ts"],
}@bonsae/nrg/test/client/component/schemas also exports loadRegistry(cwd?) and serializeRegistry(registry) for fully custom setups.
Driving state in tests
Both node and errors are plain reactive objects — there are no special setters. Mutate them directly and mounted components react, exactly like in the real editor:
const { node, errors, provide } = createNode({ name: "ok" });
const component = render(MyForm, { global: { provide } });
node.name = "renamed"; // form re-renders, schema (if any) revalidates
errors["node.connection"] = "Connection is required"; // simulate an error
await vi.waitFor(() => {
// assert how your component renders the error
});
delete errors["node.connection"]; // clear itWhen a configSchema is provided, validation owns errors — manual entries are recomputed away on the next node mutation. Inject errors by hand only in schema-less setups.
Examples
import { describe, test, expect, vi } from "vitest";
import { render } from "vitest-browser-vue";
import { createNode } from "@bonsae/nrg/test/client/component";
import MyForm from "../../../src/client/components/my-form.vue";
describe("my-form component", () => {
test("renders fields from injected node", async () => {
const { provide } = createNode({
name: "test",
url: "https://example.com",
});
const component = render(MyForm, {
global: { provide },
});
await expect
.element(component.getByDisplayValue("test"))
.toBeInTheDocument();
});
test("accesses node id for API calls", async () => {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: () => ({}) });
vi.stubGlobal("fetch", fetchSpy);
const { node, provide } = createNode({ name: "test" });
render(MyForm, { global: { provide } });
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(`my-api/${node.id}`);
});
});
test("asserts RED.editor API calls", async () => {
const { RED, provide } = createNode();
render(MyForm, { global: { provide } });
await vi.waitFor(() => {
expect(RED.editor.createEditor).toHaveBeenCalled();
});
});
test("validates required fields", async () => {
const component = render(MyInput, {
props: { value: "", label: "Name", required: true },
});
await expect.element(component.getByText("*")).toBeInTheDocument();
});
test("emits v-model updates", async () => {
const onUpdate = vi.fn();
const component = render(MyInput, {
props: { value: "", "onUpdate:modelValue": onUpdate },
});
const input = component.container.querySelector(
"input",
) as HTMLInputElement;
input.value = "new value";
input.dispatchEvent(new Event("input"));
expect(onUpdate).toHaveBeenCalledWith("new value");
});
test("reveals conditional fields when the node changes", async () => {
const { node, provide } = createNode({ apexType: "invocable" });
const component = render(MyForm, { global: { provide } });
expect(component.container.textContent).not.toContain("URL Mapping");
node.apexType = "rest"; // reactive — the form re-renders
await vi.waitFor(() => {
expect(component.container.textContent).toContain("URL Mapping");
});
});
test("clears the validation error once the node is valid", async () => {
const { node, errors, provide } = createNode({
type: "my-node",
configs: { name: "" },
});
render(MyForm, { global: { provide } });
expect(errors["node.name"]).toBeDefined();
node.name = "My Node";
await vi.waitFor(() => {
expect(errors["node.name"]).toBeUndefined();
});
});
});Translations
Component tests use key-passthrough mocks — the setup file installs a $i18n mock and RED._ that return the translation key as-is. This lets you verify the correct keys are used, but not that translations resolve to the right text. To test that translations are properly loaded and rendered, use Browser E2E Testing where Node-RED loads the real locale files.
Client E2E Testing
E2E tests start a real Node-RED instance with your nodes installed and drive the editor with Playwright. They test the full stack — schema-driven form generation, validation messages, TypedInput widgets, config node selectors, and locale resolution.
Setup
1. Install dependencies
pnpm add -D vitestplaywright is an optional peer dependency — install it as shown in Dependencies.
2. Create a tsconfig
// tests/client/e2e/tsconfig.json
{
"extends": "@bonsae/nrg/tsconfig/test/client/e2e.json",
"include": ["**/*.ts"]
}3. Create a vitest config
// vitest.client.e2e.config.ts
import { defineConfig } from "vitest/config";
import { defaultConfig } from "@bonsae/nrg/test/client/e2e/config";
export default defineConfig({
...defaultConfig,
test: {
...defaultConfig.test,
globalSetup: "tests/client/e2e/global-setup.ts",
include: ["tests/client/e2e/**/*.test.ts"],
},
});The defaultConfig provides:
testTimeout: 60_000hookTimeout: 120_000- a default
includeoftests/client/e2e/**/*.test.ts
4. Create a global setup file
// tests/client/e2e/global-setup.ts
import {
setup as baseSetup,
teardown as baseTeardown,
} from "@bonsae/nrg/test/client/e2e";
export async function setup() {
await baseSetup({
flow: [
{ id: "tab1", type: "tab", label: "E2E Tests" },
{
id: "n1",
type: "my-node",
z: "tab1",
name: "",
x: 250,
y: 200,
wires: [[]],
},
],
});
}
export async function teardown() {
await baseTeardown();
}If your project uses a node-red.settings.ts file that is not at the project root, pass settingsFile with the path to it:
await baseSetup({
settingsFile: "config/node-red.settings.ts",
flow: [
/* ... */
],
});5. Create a test file
// tests/client/e2e/my-node.test.ts
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { chromium, type Browser } from "playwright";
import { NodeRedEditor } from "@bonsae/nrg/test/client/e2e";
describe("my-node editor", () => {
let browser: Browser;
let editor: NodeRedEditor;
beforeAll(async () => {
const port = Number(process.env.NODE_RED_PORT);
browser = await chromium.launch();
const page = await browser.newPage();
editor = new NodeRedEditor(page, port);
await editor.open();
});
afterAll(async () => {
await browser.close();
});
test("name field accepts input", async () => {
await editor.editNode("n1");
const name = editor.field("Name");
await name.fill("Test Node");
await editor.clickDone();
await editor.editNode("n1");
expect(await name.getValue()).toBe("Test Node");
await editor.clickCancel();
});
});6. Add a test script
{
"scripts": {
"test:client:e2e": "vitest run --config vitest.client.e2e.config.ts"
}
}API
NodeRedEditor
Controls the Node-RED editor page. Wraps a Playwright Page instance.
const editor = new NodeRedEditor(page, port, {
screenshotDir: "test-results/screenshots", // optional
});| Method | Description |
|---|---|
editor.open() | Navigate to Node-RED and wait for the editor to load |
editor.editNode(nodeId) | Open the edit dialog for a node |
editor.clickDone() | Click the Done button and wait for the tray to close |
editor.clickCancel() | Click the Cancel button and wait for the tray to close |
editor.clickConfigDone() | Close the config-node tray stacked above the node tray |
editor.clickConfigCancel() | Cancel the config-node tray |
editor.field(label) | Get a NodeRedField for the form row with the given label (scoped to the topmost tray) |
editor.getNode(nodeId) | JSON-safe snapshot of a node in the editor model — assert persistence after clickDone() |
editor.clickDeploy() | Click Deploy (confirming the dialog if needed) and wait for a clean workspace |
editor.getDeployedFlow() | Fetch the deployed flow from the runtime (GET /flows) |
editor.getNodePortCount(nodeId) | Count the output ports rendered for a node on the canvas |
editor.toggleLifecyclePort(ariaLabel) | Click an Error/Complete/Status lifecycle output-port toggle by its accessible name |
editor.getNodeLabel(nodeId) | The node's label text on the canvas |
editor.getNodeStatus(nodeId) | The status text under the node ("" when none) |
editor.deployFlow(flow) | Deploy a flow via the REST API and reload the page |
editor.screenshot(name) | Take a full-page screenshot, returns the file path |
editor.closeAllTrays() | Best-effort close of every open tray — keeps a failed test from leaking state |
editor.expectNoPageErrors() | Assert no uncaught JavaScript errors occurred, then clear the list — call it from afterEach |
editor.tray | Locator for the tray body wrapper |
editor.errors | Array of captured page error messages |
Isolate tests from each other and fail any test that triggers an uncaught editor error:
afterEach(async () => {
await editor.closeAllTrays();
editor.expectNoPageErrors();
});NodeRedField
Represents a single form row in the node edit dialog. All field types (text, number, boolean, typed input, config input, code editor, textarea) are accessed through the same class.
const name = editor.field("Name");Input fields (text, number, password):
| Method / Property | Description |
|---|---|
field.input | Locator for the <input> element |
field.fill(value) | Set the input value |
field.clear() | Clear the input value |
field.getValue() | Get the current input value |
field.getInputType() | Get the input type attribute ("text", "number", "password") |
Boolean fields (toggle, checkbox):
| Method / Property | Description |
|---|---|
field.toggleSlider | Locator for the NRG toggle slider |
field.toggle() | Click the toggle slider |
field.checkbox | Locator for the checkbox input |
Typed input fields (TypedInput, enum select, multi-select):
| Method / Property | Description |
|---|---|
field.typedInputContainer | Locator for the typed input container |
field.getSelectedType() | Get the currently selected type (e.g. "msg", "str") |
field.getSelectedValue() | Get the current typed input value |
field.getTypeMenuValues() | Open the type dropdown, return all type values, close it |
field.selectType(type) | Open the type dropdown and select a type |
field.getOptionMenuLabels() | Open the option dropdown, return all labels, close it |
Config input fields (NodeRef):
| Method / Property | Description |
|---|---|
field.select | Locator for the <select> element |
field.editButton | Locator for the edit (pencil) button |
field.addButton | Locator for the add (plus) button |
field.openAddConfig() | Click + and wait for the config tray to open |
field.openEditConfig() | Click the pencil and wait for the config tray to open |
field.getSelectedOption() | Get the selected option value |
field.getSelectedOptionLabel() | Get the selected option display text |
field.getOptions() | Get all option labels (excludes "Add new ...") |
Once the config tray is open, editor.field(label) resolves fields inside it (fields are scoped to the topmost tray). Close it with editor.clickConfigDone() — the node tray underneath stays open:
await editor.editNode("n1");
const server = editor.field("Server");
await server.openAddConfig();
await editor.field("Host").fill("example.com"); // config tray field
await editor.clickConfigDone();
expect(await server.getSelectedOptionLabel()).toBe("example.com");
await editor.clickDone();Code editor fields:
| Method / Property | Description |
|---|---|
field.editorWrapper | Locator for the code editor wrapper |
field.getEditorValue() | Read the code editor's content (Monaco, ACE fallback) |
field.setEditorValue(value) | Replace the code editor's content |
field.expandButton | Locator for the expand button |
Autocomplete (TypedInput types with an autoComplete source):
| Method / Property | Description |
|---|---|
field.getAutoCompleteSuggestions(prefix) | Type prefix and return the suggestion labels that appear |
Array text fields:
| Method / Property | Description |
|---|---|
field.textarea | Locator for the <textarea> element |
Validation:
| Method / Property | Description |
|---|---|
field.requiredIndicator | Locator for the required asterisk (*) |
field.errorMessage | Locator for the validation error message |
field.expectError(containing?) | Assert a validation error is visible, optionally containing text |
field.expectNoError() | Assert no validation error is visible |
Visibility:
| Method / Property | Description |
|---|---|
field.row | Locator for the entire .form-row element |
field.scrollIntoView() | Scroll the field into the viewport |
field.expectVisible() | Assert the field row is visible |
field.expectHidden() | Assert the field row is hidden |
Examples
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { chromium, type Browser } from "playwright";
import { NodeRedEditor } from "@bonsae/nrg/test/client/e2e";
describe("my-node editor", () => {
let browser: Browser;
let editor: NodeRedEditor;
beforeAll(async () => {
const port = Number(process.env.NODE_RED_PORT);
browser = await chromium.launch();
const page = await browser.newPage();
editor = new NodeRedEditor(page, port);
await editor.open();
});
afterAll(async () => {
await browser.close();
});
test("text input round-trip", async () => {
await editor.editNode("n1");
const name = editor.field("Name");
await name.fill("E2E Node");
await editor.clickDone();
await editor.editNode("n1");
expect(await name.getValue()).toBe("E2E Node");
await name.clear();
await editor.clickDone();
});
test("number input renders correctly", async () => {
await editor.editNode("n1");
const count = editor.field("Count");
expect(await count.getInputType()).toBe("number");
await editor.clickCancel();
});
test("toggle renders for boolean field", async () => {
await editor.editNode("n1");
const enabled = editor.field("Enabled");
expect(await enabled.toggleSlider.isVisible()).toBe(true);
await editor.clickCancel();
});
test("typed input shows available types", async () => {
await editor.editNode("n1");
const target = editor.field("Target");
const types = await target.getTypeMenuValues();
expect(types).toContain("msg");
expect(types).toContain("str");
await editor.clickCancel();
});
test("enum field shows options", async () => {
await editor.editNode("n1");
const color = editor.field("Color");
const labels = await color.getOptionMenuLabels();
expect(labels).toEqual(["red", "green", "blue"]);
await editor.clickCancel();
});
test("config input shows registered config nodes", async () => {
await editor.editNode("n1");
const server = editor.field("Server");
expect(await server.select.isVisible()).toBe(true);
expect(await server.editButton.isVisible()).toBe(true);
const options = await server.getOptions();
expect(options).toContain("Test Server");
await editor.clickCancel();
});
test("validation error appears for invalid input", async () => {
await editor.editNode("n1");
const name = editor.field("Name");
await name.expectError("must NOT have fewer than 1 characters");
await editor.clickCancel();
});
test("validation error clears when corrected", async () => {
await editor.editNode("n1");
const name = editor.field("Name");
await name.expectError();
await name.fill("Valid");
await name.expectNoError();
await name.clear();
await editor.clickCancel();
});
test("required indicator is visible", async () => {
await editor.editNode("n1");
const name = editor.field("Name");
expect(await name.requiredIndicator.isVisible()).toBe(true);
expect(await name.requiredIndicator.textContent()).toBe("*");
await editor.clickCancel();
});
test("screenshots for visual review", async () => {
await editor.editNode("n1");
const name = editor.field("Name");
await name.scrollIntoView();
await editor.screenshot("name-field");
await editor.clickCancel();
});
test("no page errors after interaction", async () => {
await editor.editNode("n1");
await editor.clickCancel();
editor.expectNoPageErrors();
});
test("labels display translated text", async () => {
await editor.editNode("n1");
// Node-RED loads locales/<lang>/my-node.json at runtime.
// E2E tests run against the real editor, so translations are resolved.
const name = editor.field("Name");
await name.expectVisible();
// Verify the label shows the translated string, not the i18n key
const timeout = editor.field("Timeout");
await timeout.expectVisible();
await editor.clickCancel();
});
});Multi-browser testing
Run the same tests across Chromium, Firefox, and WebKit using describe.each:
import { chromium, firefox, webkit, type BrowserType } from "playwright";
const BROWSERS: Array<{ name: string; type: BrowserType }> = [
{ name: "chromium", type: chromium },
{ name: "firefox", type: firefox },
{ name: "webkit", type: webkit },
];
describe.each(BROWSERS)("my-node editor ($name)", ({ type }) => {
let browser: Browser;
let editor: NodeRedEditor;
beforeAll(async () => {
const port = Number(process.env.NODE_RED_PORT);
browser = await type.launch();
const page = await browser.newPage();
editor = new NodeRedEditor(page, port);
await editor.open();
});
afterAll(async () => {
await browser.close();
});
// ... tests run once per browser engine
});Testing locales
Node-RED resolves the editor language from the browser. Force a locale through the Playwright context to assert translated labels:
const page = await browser.newPage({ locale: "pt-BR" });
const editor = new NodeRedEditor(page, port);
await editor.open();
await editor.editNode("n1");
await editor.field("Nome").expectVisible(); // pt-BR label from your locale files