From 628e97c0b725369a0361036186074a5db67ad6c0 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Fri, 15 May 2026 14:32:42 +0930 Subject: [PATCH 1/3] pytest: add shim so we can create plugins inline in tests. Avoids having to create a special test plugin most of the time. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Rusty Russell --- .../pyln/testing/inline-plugin.py | 25 +++++++ contrib/pyln-testing/pyln/testing/utils.py | 74 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100755 contrib/pyln-testing/pyln/testing/inline-plugin.py diff --git a/contrib/pyln-testing/pyln/testing/inline-plugin.py b/contrib/pyln-testing/pyln/testing/inline-plugin.py new file mode 100755 index 000000000000..5f595b6dc9fb --- /dev/null +++ b/contrib/pyln-testing/pyln/testing/inline-plugin.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Generic inline plugin shim: bridges lightningd stdio <-> inline-plugin.sock in cwd. +Used by inline_plugin() in pyln/testing/utils.py.""" +import os +import socket +import sys +import threading + + +def _stdin_to_sock(conn): + while chunk := sys.stdin.buffer.read1(4096): + conn.sendall(chunk) + # Stdin closed means lightningd is done with us: exit immediately so the + # OS closes the socket and the serve thread can accept the next connection. + os._exit(0) + + +conn = socket.socket(socket.AF_UNIX) +conn.connect('inline-plugin.sock') + +threading.Thread(target=_stdin_to_sock, args=(conn,), daemon=True).start() + +while chunk := conn.recv(4096): + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() diff --git a/contrib/pyln-testing/pyln/testing/utils.py b/contrib/pyln-testing/pyln/testing/utils.py index 1065838c5867..56c24d06bf54 100644 --- a/contrib/pyln-testing/pyln/testing/utils.py +++ b/contrib/pyln-testing/pyln/testing/utils.py @@ -11,6 +11,7 @@ from pyln.client import LightningRpc from pyln.client import Millisatoshi from pyln.client import NodeVersion +from pyln.client import Plugin import ephemeral_port_reserve # type: ignore import json @@ -22,6 +23,7 @@ import random import re import shutil +import socket import sqlite3 import string import struct @@ -29,6 +31,7 @@ import sys import threading import time +import types import warnings BITCOIND_CONFIG = { @@ -78,6 +81,8 @@ def env(name, default=None): VALGRIND = env("VALGRIND") == "1" TEST_NETWORK = env("TEST_NETWORK", 'regtest') TEST_DEBUG = env("TEST_DEBUG", "0") == "1" + +INLINE_PLUGIN_PATH = os.path.join(os.path.dirname(__file__), 'inline-plugin.py') SLOW_MACHINE = env("SLOW_MACHINE", "0") == "1" DEPRECATED_APIS = env("DEPRECATED_APIS", "0") == "1" TIMEOUT = int(env("TIMEOUT", 180 if SLOW_MACHINE else 60)) @@ -1786,7 +1791,8 @@ def get_nodes(self, num_nodes, opts=None): def get_node(self, node_id=None, options=None, dbfile=None, bkpr_dbfile=None, feerates=(15000, 11000, 7500, 3750), start=True, wait_for_bitcoind_sync=True, may_fail=False, - expect_fail=False, cleandir=True, gossip_store_file=None, unused_grpc_port=True, **kwargs): + expect_fail=False, cleandir=True, gossip_store_file=None, unused_grpc_port=True, + inline_plugin=None, **kwargs): node_id = self.get_node_id() if not node_id else node_id port = reserve_unused_port() grpc_port = self.get_unused_port() if unused_grpc_port else None @@ -1830,6 +1836,11 @@ def get_node(self, node_id=None, options=None, dbfile=None, shutil.copy(gossip_store_file, os.path.join(node.daemon.lightning_dir, TEST_NETWORK, 'gossip_store')) + if inline_plugin is not None: + if 'plugin' not in node.daemon.opts: + node.daemon.opts['plugin'] = INLINE_PLUGIN_PATH + _inline_plugin(node, inline_plugin) + if start: try: node.start(wait_for_bitcoind_sync) @@ -1944,3 +1955,64 @@ def killall(self, expected_successes): drop_unused_port(p) return not unexpected_fail, err_msgs + + +def _inline_plugin(node, setup_fn): + """Set up an inline plugin serve thread for a not-yet-started node. + + Normally called via get_node(inline_plugin=setup_fn). The plugin's cwd + (set by lightningd) is node.daemon.lightning_dir/TEST_NETWORK/, which is + where the shim looks for inline-plugin.sock. + + Example:: + + def setup(plugin): + @plugin.method('greet') + def greet(name, plugin): + return {'message': f'hello {name}'} + + l1 = node_factory.get_node(inline_plugin=setup) + assert l1.rpc.greet('world') == {'message': 'hello world'} + """ + sock_path = os.path.join(node.daemon.lightning_dir, TEST_NETWORK, 'inline-plugin.sock') + srv = socket.socket(socket.AF_UNIX) + srv.bind(sock_path) + srv.listen(1) + + plugin = Plugin(autopatch=False) + setup_fn(plugin) + + def serve(): + while True: + conn, _ = srv.accept() + + class _SockWriter: + def write(self, data): + try: + conn.sendall(data) + except OSError: + pass + def flush(self): + pass + + writer = _SockWriter() + plugin.stdout = types.SimpleNamespace(buffer=writer, flush=writer.flush) + + partial = b"" + while True: + try: + chunk = conn.recv(4096) + except OSError: + break + if not chunk: + break + partial += chunk + msgs = partial.split(b'\n\n') + if len(msgs) < 2: + continue + try: + partial = plugin._multi_dispatch(msgs) + except Exception: + break + + threading.Thread(target=serve, daemon=True).start() From 1365083d25cfa2a1e195b91db8847d3a02198b6e Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Fri, 15 May 2026 14:32:42 +0930 Subject: [PATCH 2/3] pytest: convert many tests to use inline_plugin helper. Reduces the number of misc test plugins. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Rusty Russell --- tests/plugins/block_added.py | 21 -- tests/plugins/custom_notifications.py | 55 ----- tests/plugins/fail_htlcs_invalid.py | 20 -- tests/plugins/multiline-help.py | 17 -- .../onionmessage_forward_fail_notification.py | 20 -- tests/plugins/openchannel_hook_delay.py | 32 --- tests/plugins/pretend_badlog.py | 32 --- tests/plugins/print_htlc_onion.py | 19 -- tests/plugins/reject_odd_funding_amounts.py | 36 --- tests/plugins/reject_some_invoices.py | 25 -- tests/plugins/sendpay_notifications.py | 34 --- tests/plugins/shortcircuit.py | 13 -- tests/plugins/utf8.py | 14 -- tests/plugins/validatejson.py | 12 - tests/test_connection.py | 30 ++- tests/test_misc.py | 104 +++++++-- tests/test_pay.py | 58 ++++- tests/test_plugin.py | 217 ++++++++++++++---- tests/test_xpay.py | 74 ++++-- 19 files changed, 371 insertions(+), 462 deletions(-) delete mode 100755 tests/plugins/block_added.py delete mode 100755 tests/plugins/custom_notifications.py delete mode 100755 tests/plugins/fail_htlcs_invalid.py delete mode 100755 tests/plugins/multiline-help.py delete mode 100755 tests/plugins/onionmessage_forward_fail_notification.py delete mode 100755 tests/plugins/openchannel_hook_delay.py delete mode 100755 tests/plugins/pretend_badlog.py delete mode 100755 tests/plugins/print_htlc_onion.py delete mode 100755 tests/plugins/reject_odd_funding_amounts.py delete mode 100755 tests/plugins/reject_some_invoices.py delete mode 100755 tests/plugins/sendpay_notifications.py delete mode 100755 tests/plugins/shortcircuit.py delete mode 100755 tests/plugins/utf8.py delete mode 100755 tests/plugins/validatejson.py diff --git a/tests/plugins/block_added.py b/tests/plugins/block_added.py deleted file mode 100755 index 9da46587e087..000000000000 --- a/tests/plugins/block_added.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 - -from pyln.client import Plugin - - -plugin = Plugin() - -blocks_catched = [] - - -@plugin.subscribe("block_added") -def notify_block_added(plugin, block_added, **kwargs): - blocks_catched.append(block_added["height"]) - - -@plugin.method("blockscatched") -def return_moves(plugin): - return blocks_catched - - -plugin.run() diff --git a/tests/plugins/custom_notifications.py b/tests/plugins/custom_notifications.py deleted file mode 100755 index 1a3d92f18fc7..000000000000 --- a/tests/plugins/custom_notifications.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -from pyln.client import Plugin - - -plugin = Plugin() - - -@plugin.subscribe("custom") -def on_custom_notification(origin, message, **kwargs): - plugin.log("Got a custom notification {} from plugin {}".format(message, origin)) - - -@plugin.method("emit") -def emit(plugin): - """Emit a simple string notification to topic "custom" - """ - plugin.notify("custom", {'message': "Hello world"}) - - -@plugin.method("faulty-emit") -def faulty_emit(plugin): - """Emit a simple string notification to topic "custom" - """ - plugin.notify("ididntannouncethis", {'message': "Hello world"}) - - -@plugin.subscribe("pay_success") -def on_pay_success(origin, pay_success, **kwargs): - plugin.log( - "Got a pay_success notification from plugin {} for payment_hash {}".format( - origin, - pay_success['payment_hash'] - ) - ) - - -@plugin.subscribe("pay_part_start") -def on_pay_part_start(origin, **kwargs): - plugin.log("Got pay_part_start: {}".format(kwargs)) - - -@plugin.subscribe("pay_part_end") -def on_pay_part_end(origin, **kwargs): - plugin.log("Got pay_part_end: {}".format(kwargs)) - - -@plugin.subscribe("ididntannouncethis") -def on_faulty_emit(origin, payload, **kwargs): - """We should never receive this as it gets dropped. - """ - plugin.log("Got the ididntannouncethis event") - - -plugin.add_notification_topic("custom") -plugin.run() diff --git a/tests/plugins/fail_htlcs_invalid.py b/tests/plugins/fail_htlcs_invalid.py deleted file mode 100755 index 95881d8b756d..000000000000 --- a/tests/plugins/fail_htlcs_invalid.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 - -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.hook("htlc_accepted") -def on_htlc_accepted(onion, plugin, **kwargs): - plugin.log("Failing htlc on purpose with invalid onion failure") - plugin.log("onion: %r" % (onion)) - # WIRE_TEMPORARY_CHANNEL_FAILURE = 0x1007 - # This failure code should be followed by a - # `channel_update`; we deliberately return - # a 0-length `channel_update` to trigger - # issue #3757 reported by @sumBTC. - return {"result": "fail", "failure_message": "10070000"} - - -plugin.run() diff --git a/tests/plugins/multiline-help.py b/tests/plugins/multiline-help.py deleted file mode 100755 index 4b8b3a7f87f4..000000000000 --- a/tests/plugins/multiline-help.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -from pyln.client import Plugin, Millisatoshi - - -plugin = Plugin() - - -@plugin.method("helpme") -def helpme(plugin, msat: Millisatoshi): - """This is a message which consumes multiple lines and thus should - be well-formatted by lightning-cli help - - """ - return {'help': msat} - - -plugin.run() diff --git a/tests/plugins/onionmessage_forward_fail_notification.py b/tests/plugins/onionmessage_forward_fail_notification.py deleted file mode 100755 index 69606e2334e2..000000000000 --- a/tests/plugins/onionmessage_forward_fail_notification.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 - -""" We get an onionmessage_forward_fail notification, and open a connection -""" -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.subscribe("onionmessage_forward_fail") -def on_onionmessage_forward_fail(onionmessage_forward_fail, **kwargs): - plugin.log(f"Received onionmessage_forward_fail {onionmessage_forward_fail}") - - plugin.rpc.connect(onionmessage_forward_fail['next_node_id']) - # injectonionmessage expects to unwrap, so hand it *incoming* - plugin.rpc.injectonionmessage(onionmessage_forward_fail['path_key'], - onionmessage_forward_fail['incoming']) - - -plugin.run() diff --git a/tests/plugins/openchannel_hook_delay.py b/tests/plugins/openchannel_hook_delay.py deleted file mode 100755 index 09ec2abdc335..000000000000 --- a/tests/plugins/openchannel_hook_delay.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -"""Plugin to test openchannel_hook - -Will simply accept any channel. Useful fot testing chained hook. -""" - -from pyln.client import Plugin -import time - -plugin = Plugin() - - -@plugin.hook('openchannel') -def on_openchannel(openchannel, plugin, **kwargs): - delaytime = float(plugin.get_option('delaytime')) - msg = f'delaying WIRE_ACCEPT_CHANNEL for {delaytime}s' - plugin.log(msg) - time.sleep(delaytime) - return {'result': 'continue'} - - -@plugin.hook('openchannel2') -def on_openchannel2(openchannel2, plugin, **kwargs): - delaytime = float(plugin.get_option('delaytime')) - msg = f'delaying WIRE_ACCEPT_CHANNEL for {delaytime}s' - plugin.log(msg) - time.sleep(delaytime) - return {'result': 'continue'} - - -plugin.add_option('delaytime', '10', 'How long to hold the WIRE_OPEN_CHANNEL.') -plugin.run() diff --git a/tests/plugins/pretend_badlog.py b/tests/plugins/pretend_badlog.py deleted file mode 100755 index a255fe11874b..000000000000 --- a/tests/plugins/pretend_badlog.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -"""This plugin is used to check that warning(unusual/broken level log) calls are working correctly. -""" -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.init() -def init(configuration, options, plugin): - plugin.log("initialized") - - -@plugin.subscribe("warning") -def notify_warning(plugin, warning, **kwargs): - plugin.log("Received warning") - plugin.log("level: {}".format(warning['level'])) - plugin.log("time: {}".format(warning['time'])) - plugin.log("source: {}".format(warning['source'])) - plugin.log("log: {}".format(warning['log'])) - - -@plugin.method("pretendbad") -def pretend_bad(event, level, plugin): - """Log an specified level entry. - And in plugin, we use 'warn'/'error' instead of - 'unusual'/'broken' - """ - plugin.log("{}".format(event), level) - - -plugin.run() diff --git a/tests/plugins/print_htlc_onion.py b/tests/plugins/print_htlc_onion.py deleted file mode 100755 index f1557dd20190..000000000000 --- a/tests/plugins/print_htlc_onion.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -"""Plugin that prints out HTLC onions. - -We use this to check whether they're TLV or not - -""" - -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.hook("htlc_accepted") -def on_htlc_accepted(htlc, onion, plugin, **kwargs): - plugin.log("Got onion {}".format(onion)) - return {'result': 'continue'} - - -plugin.run() diff --git a/tests/plugins/reject_odd_funding_amounts.py b/tests/plugins/reject_odd_funding_amounts.py deleted file mode 100755 index fc4d67a338b6..000000000000 --- a/tests/plugins/reject_odd_funding_amounts.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -"""Simple plugin to test the openchannel_hook. - -We just refuse to let them open channels with an odd amount of millisatoshis. -""" - -from pyln.client import Plugin, Millisatoshi - -plugin = Plugin() - - -def run_check(funding_amt_str): - if Millisatoshi(funding_amt_str).to_satoshi() % 2 == 1: - return {'result': 'reject', 'error_message': "I don't like odd amounts"} - - return {'result': 'continue'} - - -@plugin.hook('openchannel') -def on_openchannel(openchannel, plugin, **kwargs): - print("{} VARS".format(len(openchannel.keys()))) - for k in sorted(openchannel.keys()): - print("{}={}".format(k, openchannel[k])) - return run_check(openchannel['funding_msat']) - - -@plugin.hook('openchannel2') -def on_openchannel2(openchannel2, plugin, **kwargs): - print("{} VARS".format(len(openchannel2.keys()))) - for k in sorted(openchannel2.keys()): - print("{}={}".format(k, openchannel2[k])) - - return run_check(openchannel2['their_funding_msat']) - - -plugin.run() diff --git a/tests/plugins/reject_some_invoices.py b/tests/plugins/reject_some_invoices.py deleted file mode 100755 index 321e4e7a2d52..000000000000 --- a/tests/plugins/reject_some_invoices.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -"""Simple plugin to test the invoice_payment_hook. - -We just refuse to let them pay invoices with preimages divisible by 16. -""" - -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.hook('invoice_payment') -def on_payment(payment, plugin, **kwargs): - print("label={}".format(payment['label'])) - print("msat={}".format(payment['msat'])) - print("preimage={}".format(payment['preimage'])) - - if payment['preimage'].endswith('0'): - # WIRE_TEMPORARY_NODE_FAILURE = 0x2002 - return {'failure_message': "2002"} - - return {'result': 'continue'} - - -plugin.run() diff --git a/tests/plugins/sendpay_notifications.py b/tests/plugins/sendpay_notifications.py deleted file mode 100755 index 0f83290d24a5..000000000000 --- a/tests/plugins/sendpay_notifications.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -"""This plugin is used to check that sendpay_success and sendpay_failure calls are working correctly. -""" -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.init() -def init(configuration, options, plugin): - plugin.success_list = [] - plugin.failure_list = [] - - -@plugin.subscribe("sendpay_success") -def notify_sendpay_success(plugin, sendpay_success): - plugin.log("Received a sendpay_success: id={}, payment_hash={}".format(sendpay_success['id'], sendpay_success['payment_hash'])) - plugin.success_list.append(sendpay_success) - - -@plugin.subscribe("sendpay_failure") -def notify_sendpay_failure(plugin, sendpay_failure): - plugin.log("Received a sendpay_failure: id={}, payment_hash={}".format(sendpay_failure['data']['id'], - sendpay_failure['data']['payment_hash'])) - plugin.failure_list.append(sendpay_failure) - - -@plugin.method('listsendpays_plugin') -def record_lookup(plugin): - return {'sendpay_success': plugin.success_list, - 'sendpay_failure': plugin.failure_list} - - -plugin.run() diff --git a/tests/plugins/shortcircuit.py b/tests/plugins/shortcircuit.py deleted file mode 100755 index bdb088c15c58..000000000000 --- a/tests/plugins/shortcircuit.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 - -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.hook("htlc_accepted") -def on_htlc_accepted(onion, htlc, plugin, **kwargs): - return {"result": "resolve", "payment_key": "00" * 32} - - -plugin.run() diff --git a/tests/plugins/utf8.py b/tests/plugins/utf8.py deleted file mode 100755 index 16c3afe45083..000000000000 --- a/tests/plugins/utf8.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 -from pyln.client import Plugin - - -plugin = Plugin() - - -@plugin.method("utf8") -def echo(plugin, utf8): - assert '\\u' not in utf8 - return {'utf8': utf8} - - -plugin.run() diff --git a/tests/plugins/validatejson.py b/tests/plugins/validatejson.py deleted file mode 100755 index 8c32d4da6398..000000000000 --- a/tests/plugins/validatejson.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 -from pyln.client import Plugin - -plugin = Plugin() - - -@plugin.method('validate-json-rpc') -def validate_json_rpc(plugin, *args, **kwargs): - return {} - - -plugin.run() diff --git a/tests/test_connection.py b/tests/test_connection.py index 19135be79167..548c24ee36cd 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -4597,16 +4597,24 @@ def test_connect_ratelimit(node_factory, bitcoind): def test_onionmessage_forward_fail(node_factory, bitcoind): # The plugin will try to connect to l3, so it needs an advertized address. - l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True, - opts=[{}, - {'dev-allow-localhost': None, - 'may_reconnect': True, - 'dev-no-reconnect': None, - 'plugin': os.path.join(os.getcwd(), 'tests/plugins/onionmessage_forward_fail_notification.py'), - }, - {'dev-allow-localhost': None, - 'dev-no-reconnect': None, - 'may_reconnect': True}]) + def setup(plugin): + @plugin.subscribe("onionmessage_forward_fail") + def on_onionmessage_forward_fail(onionmessage_forward_fail, **kwargs): + plugin.log(f"Received onionmessage_forward_fail {onionmessage_forward_fail}") + plugin.rpc.connect(onionmessage_forward_fail['next_node_id']) + # injectonionmessage expects to unwrap, so hand it *incoming* + plugin.rpc.injectonionmessage(onionmessage_forward_fail['path_key'], + onionmessage_forward_fail['incoming']) + + l1 = node_factory.get_node() + l2 = node_factory.get_node(inline_plugin=setup, + options={'dev-allow-localhost': None, + 'dev-no-reconnect': None}, + may_reconnect=True) + l3 = node_factory.get_node(options={'dev-allow-localhost': None, + 'dev-no-reconnect': None}, + may_reconnect=True) + node_factory.join_nodes([l1, l2, l3], wait_for_announce=True) offer = l3.rpc.offer(300, "test_onionmessage_forward_fail") l2.rpc.disconnect(l3.info['id'], force=True) @@ -4614,7 +4622,7 @@ def test_onionmessage_forward_fail(node_factory, bitcoind): # The plugin in l2 fixes up the connection, so this works! l1.rpc.fetchinvoice(offer['bolt12']) - l2.daemon.is_in_log('plugin-onionmessage_forward_fail_notification.py: Received onionmessage_forward_fail') + l2.daemon.is_in_log('plugin-inline-plugin.py: Received onionmessage_forward_fail') def test_private_channel_no_reconnect(node_factory): diff --git a/tests/test_misc.py b/tests/test_misc.py index 4154ad6eba7f..560b53fbe5ac 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -999,12 +999,12 @@ def test_malformed_rpc(node_factory): def test_valid_json_cli(node_factory): """Make sure lightning-cli passes valid json values, so that rust and python plugins don't crash.""" - l1 = node_factory.get_node( - options={ - "log-level": "io", - "plugin": os.path.join(os.getcwd(), "tests/plugins/validatejson.py"), - } - ) + def setup(plugin): + @plugin.method('validate-json-rpc') + def validate_json_rpc(plugin, *args, **kwargs): + return {} + + l1 = node_factory.get_node(options={"log-level": "io"}, inline_plugin=setup) # If passed as a literal number rust's serde_json::from_str will fail as the # leading zero makes it invalid for an integer. nodeid = "030000000000000000000000000000000000000000000000000000000000000001" @@ -1203,7 +1203,18 @@ def test_cli(node_factory): def test_cli_multiline_help(node_factory): - l1 = node_factory.get_node(options={'plugin': os.path.join(os.getcwd(), 'tests/plugins/multiline-help.py')}) + def setup(plugin): + from pyln.client import Millisatoshi + + @plugin.method("helpme") + def helpme(plugin, msat: Millisatoshi): + """This is a message which consumes multiple lines and thus should + be well-formatted by lightning-cli help + + """ + return {'help': msat} + + l1 = node_factory.get_node(inline_plugin=setup) out = subprocess.check_output(['cli/lightning-cli', '--network={}'.format(TEST_NETWORK), @@ -1431,15 +1442,40 @@ def test_funding_reorg_private(node_factory, bitcoind): """ # Rescan to detect reorg at restart and may_reconnect so channeld # will restart. Reorg can cause bad gossip msg. - opts = {'funding-confirms': 2, 'rescan': 10, 'may_reconnect': True, - 'allow_bad_gossip': True, - # gossipd send lightning update for original channel. - 'allow_warning': True, - 'dev-fast-reconnect': None, - # if it's not zeroconf, we'll terminate on reorg. - 'plugin': os.path.join(os.getcwd(), 'tests/plugins/zeroconf-selective.py'), - 'zeroconf_allow': 'any'} - l1, l2 = node_factory.line_graph(2, fundchannel=False, opts=opts) + def setup(plugin): + plugin.add_option( + 'zeroconf_allow', + '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', + 'A node_id to allow zeroconf channels from', + ) + plugin.add_option( + 'zeroconf_mindepth', + 0, + 'Number of confirmations to require from allowlisted peers', + ) + + @plugin.hook('openchannel') + def on_openchannel(openchannel, plugin, **kwargs): + plugin.log(repr(openchannel)) + mindepth = int(plugin.options['zeroconf_mindepth']['value']) + + if openchannel['id'] == plugin.options['zeroconf_allow']['value'] or plugin.options['zeroconf_allow']['value'] == 'any': + plugin.log(f"This peer is in the zeroconf allowlist, setting mindepth={mindepth}") + return {'result': 'continue', 'mindepth': mindepth} + else: + return {'result': 'continue'} + + cli_opts = {'funding-confirms': 2, 'rescan': 10, + 'dev-fast-reconnect': None, + # if it's not zeroconf, we'll terminate on reorg. + 'zeroconf_allow': 'any'} + node_opts = {'may_reconnect': True, + 'allow_bad_gossip': True, + # gossipd send lightning update for original channel. + 'allow_warning': True} + l1 = node_factory.get_node(inline_plugin=setup, options=cli_opts, **node_opts) + l2 = node_factory.get_node(inline_plugin=setup, options=cli_opts, **node_opts) + node_factory.join_nodes([l1, l2], fundchannel=False) l1.fundwallet(10000000) sync_blockheight(bitcoind, [l1]) # height 102 bitcoind.generate_block(3) # heights 103-105 @@ -1477,12 +1513,36 @@ def test_funding_reorg_remote_lags(node_factory, bitcoind): """Nodes may disagree about short_channel_id before channel announcement """ # may_reconnect so channeld will restart; bad gossip can happen due to reorg - opts = {'funding-confirms': 1, 'may_reconnect': True, 'allow_bad_gossip': True, - 'allow_warning': True, 'dev-fast-reconnect': None, - # if it's not zeroconf, l2 will terminate on reorg. - 'plugin': os.path.join(os.getcwd(), 'tests/plugins/zeroconf-selective.py'), - 'zeroconf_allow': 'any'} - l1, l2 = node_factory.line_graph(2, fundchannel=False, opts=opts) + def setup(plugin): + plugin.add_option( + 'zeroconf_allow', + '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', + 'A node_id to allow zeroconf channels from', + ) + plugin.add_option( + 'zeroconf_mindepth', + 0, + 'Number of confirmations to require from allowlisted peers', + ) + + @plugin.hook('openchannel') + def on_openchannel(openchannel, plugin, **kwargs): + plugin.log(repr(openchannel)) + mindepth = int(plugin.options['zeroconf_mindepth']['value']) + + if openchannel['id'] == plugin.options['zeroconf_allow']['value'] or plugin.options['zeroconf_allow']['value'] == 'any': + plugin.log(f"This peer is in the zeroconf allowlist, setting mindepth={mindepth}") + return {'result': 'continue', 'mindepth': mindepth} + else: + return {'result': 'continue'} + + cli_opts = {'funding-confirms': 1, 'dev-fast-reconnect': None, + # if it's not zeroconf, l2 will terminate on reorg. + 'zeroconf_allow': 'any'} + node_opts = {'may_reconnect': True, 'allow_bad_gossip': True, 'allow_warning': True} + l1 = node_factory.get_node(inline_plugin=setup, options=cli_opts, **node_opts) + l2 = node_factory.get_node(inline_plugin=setup, options=cli_opts, **node_opts) + node_factory.join_nodes([l1, l2], fundchannel=False) l1.fundwallet(10000000) sync_blockheight(bitcoind, [l1]) # height 102 diff --git a/tests/test_pay.py b/tests/test_pay.py index 76aff48dfb05..2aebee461eb1 100644 --- a/tests/test_pay.py +++ b/tests/test_pay.py @@ -3020,7 +3020,16 @@ def test_sendonion_rpc(node_factory): @pytest.mark.openchannel('v2') def test_partial_payment(node_factory, bitcoind, executor): # We want to test two payments at the same time, before we send commit - l1, l2, l3, l4 = node_factory.get_nodes(4, [{}] + [{'dev-disable-commit-after': 0, 'dev-no-htlc-timeout': None}] * 2 + [{'plugin': os.path.join(os.getcwd(), 'tests/plugins/print_htlc_onion.py')}]) + def setup(plugin): + @plugin.hook("htlc_accepted") + def on_htlc_accepted(htlc, onion, plugin, **kwargs): + plugin.log("Got onion {}".format(onion)) + return {'result': 'continue'} + + l1 = node_factory.get_node() + l2 = node_factory.get_node(options={'dev-disable-commit-after': 0, 'dev-no-htlc-timeout': None}) + l3 = node_factory.get_node(options={'dev-disable-commit-after': 0, 'dev-no-htlc-timeout': None}) + l4 = node_factory.get_node(inline_plugin=setup) # Two routes to l4: one via l2, and one via l3. l1.rpc.connect(l2.info['id'], 'localhost', l2.port) @@ -3156,7 +3165,7 @@ def test_partial_payment(node_factory, bitcoind, executor): assert res['partid'] == 2 for i in range(2): - line = l4.daemon.wait_for_log('print_htlc_onion.py: Got onion') + line = l4.daemon.wait_for_log('inline-plugin.py: Got onion') assert "'type': 'tlv'" in line assert "'forward_msat': 499" in line or "'forward_msat': 501" in line assert "'total_msat': 1000" in line @@ -3762,12 +3771,22 @@ def test_invalid_onion_channel_update(node_factory): even if some remote node does not send the required `channel_update`. ''' - plugin = os.path.join(os.getcwd(), 'tests/plugins/fail_htlcs_invalid.py') - l1, l2, l3 = node_factory.line_graph(3, - opts=[{}, - {'plugin': plugin}, - {}], - wait_for_announce=True) + def setup(plugin): + @plugin.hook("htlc_accepted") + def on_htlc_accepted(onion, plugin, **kwargs): + plugin.log("Failing htlc on purpose with invalid onion failure") + plugin.log("onion: %r" % (onion)) + # WIRE_TEMPORARY_CHANNEL_FAILURE = 0x1007 + # This failure code should be followed by a + # `channel_update`; we deliberately return + # a 0-length `channel_update` to trigger + # issue #3757 reported by @sumBTC. + return {"result": "fail", "failure_message": "10070000"} + + l1 = node_factory.get_node() + l2 = node_factory.get_node(inline_plugin=setup) + l3 = node_factory.get_node() + node_factory.join_nodes([l1, l2, l3], wait_for_announce=True) l1id = l1.info['id'] @@ -5880,11 +5899,26 @@ def test_blinded_reply_path_scid(node_factory): def test_pay_while_opening_channel(node_factory, bitcoind, executor): - delay_plugin = {'plugin': os.path.join(os.getcwd(), - 'tests/plugins/openchannel_hook_delay.py'), - 'delaytime': '10'} + def setup(plugin): + import time + plugin.add_option('delaytime', '10', 'How long to hold the WIRE_OPEN_CHANNEL.') + + @plugin.hook('openchannel') + def on_openchannel(openchannel, plugin, **kwargs): + delaytime = float(plugin.get_option('delaytime')) + plugin.log(f'delaying WIRE_ACCEPT_CHANNEL for {delaytime}s') + time.sleep(delaytime) + return {'result': 'continue'} + + @plugin.hook('openchannel2') + def on_openchannel2(openchannel2, plugin, **kwargs): + delaytime = float(plugin.get_option('delaytime')) + plugin.log(f'delaying WIRE_ACCEPT_CHANNEL for {delaytime}s') + time.sleep(delaytime) + return {'result': 'continue'} + l1, l2 = node_factory.line_graph(2, fundamount=10**6, wait_for_announce=True) - l3 = node_factory.get_node(options=delay_plugin) + l3 = node_factory.get_node(inline_plugin=setup, options={'delaytime': '10'}) l1.connect(l3) executor.submit(l1.rpc.fundchannel, l3.info['id'], 100000) wait_for(lambda: l1.rpc.listpeerchannels(l3.info['id'])['channels'] != []) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 62bf2c790e22..5c9565c4fa30 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -667,8 +667,13 @@ def test_db_hook_multiple(node_factory, executor): def test_utf8_passthrough(node_factory, executor): - l1 = node_factory.get_node(options={'plugin': os.path.join(os.getcwd(), 'tests/plugins/utf8.py'), - 'log-level': 'io'}) + def setup(plugin): + @plugin.method("utf8") + def echo(plugin, utf8): + assert '\\u' not in utf8 + return {'utf8': utf8} + + l1 = node_factory.get_node(inline_plugin=setup, options={'log-level': 'io'}) # This works because Python unmangles. res = l1.rpc.call('utf8', ['ナンセンス 1杯']) @@ -688,8 +693,22 @@ def test_utf8_passthrough(node_factory, executor): def test_invoice_payment_hook(node_factory): """ l1 uses the reject-payment plugin to reject invoices with odd preimages. """ - opts = [{}, {'plugin': os.path.join(os.getcwd(), 'tests/plugins/reject_some_invoices.py')}] - l1, l2 = node_factory.line_graph(2, opts=opts) + def setup(plugin): + @plugin.hook('invoice_payment') + def on_payment(payment, plugin, **kwargs): + plugin.log("label={}".format(payment['label'])) + plugin.log("msat={}".format(payment['msat'])) + plugin.log("preimage={}".format(payment['preimage'])) + + if payment['preimage'].endswith('0'): + # WIRE_TEMPORARY_NODE_FAILURE = 0x2002 + return {'failure_message': "2002"} + + return {'result': 'continue'} + + l1 = node_factory.get_node() + l2 = node_factory.get_node(inline_plugin=setup) + node_factory.join_nodes([l1, l2]) # This one works inv1 = l2.rpc.invoice(1230, 'label', 'description', preimage='1' * 64) @@ -731,8 +750,31 @@ def test_invoice_payment_hook_hold(node_factory, executor): def test_openchannel_hook(node_factory, bitcoind): """ l2 uses the reject_odd_funding_amounts plugin to reject some openings. """ - opts = [{}, {'plugin': os.path.join(os.getcwd(), 'tests/plugins/reject_odd_funding_amounts.py')}] - l1, l2 = node_factory.line_graph(2, fundchannel=False, opts=opts) + def setup(plugin): + from pyln.client import Millisatoshi + + def run_check(funding_amt_str): + if Millisatoshi(funding_amt_str).to_satoshi() % 2 == 1: + return {'result': 'reject', 'error_message': "I don't like odd amounts"} + return {'result': 'continue'} + + @plugin.hook('openchannel') + def on_openchannel(openchannel, plugin, **kwargs): + plugin.log("{} VARS".format(len(openchannel.keys()))) + for k in sorted(openchannel.keys()): + plugin.log("{}={}".format(k, openchannel[k])) + return run_check(openchannel['funding_msat']) + + @plugin.hook('openchannel2') + def on_openchannel2(openchannel2, plugin, **kwargs): + plugin.log("{} VARS".format(len(openchannel2.keys()))) + for k in sorted(openchannel2.keys()): + plugin.log("{}={}".format(k, openchannel2[k])) + return run_check(openchannel2['their_funding_msat']) + + l1 = node_factory.get_node() + l2 = node_factory.get_node(inline_plugin=setup) + node_factory.join_nodes([l1, l2], fundchannel=False) l1.fundwallet(10**6) # Even amount: works. @@ -776,9 +818,9 @@ def test_openchannel_hook(node_factory, bitcoind): 'push_msat': 0, }) - l2.daemon.wait_for_log('reject_odd_funding_amounts.py: {} VARS'.format(len(expected))) + l2.daemon.wait_for_log('inline-plugin.py: {} VARS'.format(len(expected))) for k, v in expected.items(): - assert l2.daemon.is_in_log('reject_odd_funding_amounts.py: {}={}'.format(k, v)) + assert l2.daemon.is_in_log('inline-plugin.py: {}={}'.format(k, v)) # Close it. txid = only_one(l1.rpc.close(l2.info['id'])['txids']) @@ -1252,11 +1294,15 @@ def test_htlc_accepted_hook_fail(node_factory): def test_htlc_accepted_hook_resolve(node_factory): """l3 creates an invoice, l2 knows the preimage and will shortcircuit. """ - l1, l2, l3 = node_factory.line_graph(3, opts=[ - {}, - {'plugin': os.path.join(os.getcwd(), 'tests/plugins/shortcircuit.py')}, - {} - ], wait_for_announce=True) + def setup(plugin): + @plugin.hook("htlc_accepted") + def on_htlc_accepted(onion, htlc, plugin, **kwargs): + return {"result": "resolve", "payment_key": "00" * 32} + + l1 = node_factory.get_node() + l2 = node_factory.get_node(inline_plugin=setup) + l3 = node_factory.get_node() + node_factory.join_nodes([l1, l2, l3], wait_for_announce=True) inv = l3.rpc.invoice(amount_msat=1000, label="lbl", description="desc", preimage="00" * 32)['bolt11'] l1.rpc.xpay(inv) @@ -1408,41 +1454,62 @@ def test_htlc_accepted_hook_forward_restart(node_factory, executor): def test_warning_notification(node_factory): """ test 'warning' notifications """ - l1 = node_factory.get_node(options={'plugin': os.path.join(os.getcwd(), 'tests/plugins/pretend_badlog.py')}, broken_log=r'Test warning notification\(for broken event\)|LINE[12]') + def setup(plugin): + @plugin.init() + def init(configuration, options, plugin): + plugin.log("initialized") + + @plugin.subscribe("warning") + def notify_warning(plugin, warning, **kwargs): + plugin.log("Received warning") + plugin.log("level: {}".format(warning['level'])) + plugin.log("time: {}".format(warning['time'])) + plugin.log("source: {}".format(warning['source'])) + plugin.log("log: {}".format(warning['log'])) + + @plugin.method("pretendbad") + def pretend_bad(event, level, plugin): + """Log an specified level entry. + And in plugin, we use 'warn'/'error' instead of + 'unusual'/'broken' + """ + plugin.log("{}".format(event), level) + + l1 = node_factory.get_node(inline_plugin=setup, broken_log=r'Test warning notification\(for broken event\)|LINE[12]') # 1. test 'warn' level event = "Test warning notification(for unusual event)" l1.rpc.call('pretendbad', {'event': event, 'level': 'warn'}) # ensure an unusual log_entry was produced by 'pretendunusual' method - l1.daemon.wait_for_log('plugin-pretend_badlog.py: Test warning notification\\(for unusual event\\)') + l1.daemon.wait_for_log('plugin-inline-plugin.py: Test warning notification\\(for unusual event\\)') # now wait for notification - l1.daemon.wait_for_log('plugin-pretend_badlog.py: Received warning') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: level: warn') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: time: *') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: source: plugin-pretend_badlog.py') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: log: Test warning notification\\(for unusual event\\)') + l1.daemon.wait_for_log('plugin-inline-plugin.py: Received warning') + l1.daemon.wait_for_log('plugin-inline-plugin.py: level: warn') + l1.daemon.wait_for_log('plugin-inline-plugin.py: time: *') + l1.daemon.wait_for_log('plugin-inline-plugin.py: source: plugin-inline-plugin.py') + l1.daemon.wait_for_log('plugin-inline-plugin.py: log: Test warning notification\\(for unusual event\\)') # 2. test 'error' level, steps like above event = "Test warning notification(for broken event)" l1.rpc.call('pretendbad', {'event': event, 'level': 'error'}) - l1.daemon.wait_for_log(r'\*\*BROKEN\*\* plugin-pretend_badlog.py: Test warning notification\(for broken event\)') + l1.daemon.wait_for_log(r'\*\*BROKEN\*\* plugin-inline-plugin.py: Test warning notification\(for broken event\)') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: Received warning') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: level: error') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: time: *') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: source: plugin-pretend_badlog.py') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: log: Test warning notification\\(for broken event\\)') + l1.daemon.wait_for_log('plugin-inline-plugin.py: Received warning') + l1.daemon.wait_for_log('plugin-inline-plugin.py: level: error') + l1.daemon.wait_for_log('plugin-inline-plugin.py: time: *') + l1.daemon.wait_for_log('plugin-inline-plugin.py: source: plugin-inline-plugin.py') + l1.daemon.wait_for_log('plugin-inline-plugin.py: log: Test warning notification\\(for broken event\\)') # Test linesplitting while we're here l1.rpc.call('pretendbad', {'event': 'LINE1\nLINE2', 'level': 'error'}) - l1.daemon.wait_for_log(r'\*\*BROKEN\*\* plugin-pretend_badlog.py: LINE1') - l1.daemon.wait_for_log(r'\*\*BROKEN\*\* plugin-pretend_badlog.py: LINE2') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: Received warning') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: log: LINE1') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: Received warning') - l1.daemon.wait_for_log('plugin-pretend_badlog.py: log: LINE2') + l1.daemon.wait_for_log(r'\*\*BROKEN\*\* plugin-inline-plugin.py: LINE1') + l1.daemon.wait_for_log(r'\*\*BROKEN\*\* plugin-inline-plugin.py: LINE2') + l1.daemon.wait_for_log('plugin-inline-plugin.py: Received warning') + l1.daemon.wait_for_log('plugin-inline-plugin.py: log: LINE1') + l1.daemon.wait_for_log('plugin-inline-plugin.py: Received warning') + l1.daemon.wait_for_log('plugin-inline-plugin.py: log: LINE2') def test_invoice_payment_notification(node_factory): @@ -1632,11 +1699,33 @@ def test_forward_event_notification(node_factory, bitcoind, executor): def test_sendpay_notifications(node_factory, bitcoind): """ test 'sendpay_success' and 'sendpay_failure' notifications """ + def setup(plugin): + @plugin.init() + def init(configuration, options, plugin): + plugin.success_list = [] + plugin.failure_list = [] + + @plugin.subscribe("sendpay_success") + def notify_sendpay_success(plugin, sendpay_success): + plugin.log("Received a sendpay_success: id={}, payment_hash={}".format(sendpay_success['id'], sendpay_success['payment_hash'])) + plugin.success_list.append(sendpay_success) + + @plugin.subscribe("sendpay_failure") + def notify_sendpay_failure(plugin, sendpay_failure): + plugin.log("Received a sendpay_failure: id={}, payment_hash={}".format(sendpay_failure['data']['id'], + sendpay_failure['data']['payment_hash'])) + plugin.failure_list.append(sendpay_failure) + + @plugin.method('listsendpays_plugin') + def record_lookup(plugin): + return {'sendpay_success': plugin.success_list, + 'sendpay_failure': plugin.failure_list} + amount = 10**8 - opts = [{'plugin': os.path.join(os.getcwd(), 'tests/plugins/sendpay_notifications.py')}, - {}, - {'may_reconnect': False}] - l1, l2, l3 = node_factory.line_graph(3, opts=opts, wait_for_announce=True) + l1 = node_factory.get_node(inline_plugin=setup) + l2 = node_factory.get_node() + l3 = node_factory.get_node(may_reconnect=False) + node_factory.join_nodes([l1, l2, l3], wait_for_announce=True) chanid23 = l2.get_channel_scid(l3) inv1 = l3.rpc.invoice(amount, "first", "desc") @@ -1663,10 +1752,32 @@ def test_sendpay_notifications(node_factory, bitcoind): def test_sendpay_notifications_nowaiter(node_factory): - opts = [{'plugin': os.path.join(os.getcwd(), 'tests/plugins/sendpay_notifications.py')}, - {}, - {'may_reconnect': False}] - l1, l2, l3 = node_factory.line_graph(3, opts=opts, wait_for_announce=True) + def setup(plugin): + @plugin.init() + def init(configuration, options, plugin): + plugin.success_list = [] + plugin.failure_list = [] + + @plugin.subscribe("sendpay_success") + def notify_sendpay_success(plugin, sendpay_success): + plugin.log("Received a sendpay_success: id={}, payment_hash={}".format(sendpay_success['id'], sendpay_success['payment_hash'])) + plugin.success_list.append(sendpay_success) + + @plugin.subscribe("sendpay_failure") + def notify_sendpay_failure(plugin, sendpay_failure): + plugin.log("Received a sendpay_failure: id={}, payment_hash={}".format(sendpay_failure['data']['id'], + sendpay_failure['data']['payment_hash'])) + plugin.failure_list.append(sendpay_failure) + + @plugin.method('listsendpays_plugin') + def record_lookup(plugin): + return {'sendpay_success': plugin.success_list, + 'sendpay_failure': plugin.failure_list} + + l1 = node_factory.get_node(inline_plugin=setup) + l2 = node_factory.get_node() + l3 = node_factory.get_node(may_reconnect=False) + node_factory.join_nodes([l1, l2, l3], wait_for_announce=True) chanid23 = l2.get_channel_scid(l3) amount = 10**8 @@ -3428,10 +3539,26 @@ def test_autoclean_once(node_factory): def test_block_added_notifications(node_factory, bitcoind): """Test if a plugin gets notifications when a new block is found""" base = bitcoind.rpc.getblockchaininfo()["blocks"] - plugin = [ - os.path.join(os.getcwd(), "tests/plugins/block_added.py"), - ] - l1 = node_factory.get_node(options={"plugin": plugin}) + + def make_setup(): + blocks_catched = [] + + def setup(plugin): + @plugin.init() + def on_init(plugin, options, configuration, **kwargs): + blocks_catched.clear() + + @plugin.subscribe("block_added") + def notify_block_added(plugin, block_added, **kwargs): + blocks_catched.append(block_added["height"]) + + @plugin.method("blockscatched") + def return_moves(plugin): + return blocks_catched + + return setup + + l1 = node_factory.get_node(inline_plugin=make_setup()) ret = l1.rpc.call("blockscatched") assert len(ret) == 1 and ret[0] == base + 0 @@ -3440,7 +3567,7 @@ def test_block_added_notifications(node_factory, bitcoind): ret = l1.rpc.call("blockscatched") assert len(ret) == 3 and ret[0] == base + 0 and ret[2] == base + 2 - l2 = node_factory.get_node(options={"plugin": plugin}) + l2 = node_factory.get_node(inline_plugin=make_setup()) ret = l2.rpc.call("blockscatched") assert len(ret) == 1 and ret[0] == base + 2 diff --git a/tests/test_xpay.py b/tests/test_xpay.py index c419589ecc9c..0339dd213e60 100644 --- a/tests/test_xpay.py +++ b/tests/test_xpay.py @@ -7,7 +7,6 @@ sync_blockheight, ) -import ast import os import pytest import re @@ -826,9 +825,46 @@ def zero_fields(obj, fieldnames): # other types are ignored return obj - plugin_path = os.path.join(os.getcwd(), 'tests/plugins/custom_notifications.py') - l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True, - opts=[{"plugin": plugin_path}, {}, {}]) + part_starts = [] + part_ends = [] + + def setup(plugin): + @plugin.subscribe("pay_part_start") + def on_pay_part_start(origin, **kwargs): + part_starts.append(kwargs) + + @plugin.subscribe("pay_part_end") + def on_pay_part_end(origin, **kwargs): + part_ends.append(kwargs) + + @plugin.subscribe("pay_success") + def on_pay_success(origin, pay_success, **kwargs): + pass + + @plugin.subscribe("custom") + def on_custom_notification(origin, message, **kwargs): + pass + + @plugin.subscribe("ididntannouncethis") + def on_faulty_emit(origin, payload, **kwargs): + pass + + @plugin.method("emit") + def emit(plugin): + """Emit a simple string notification to topic "custom" """ + plugin.notify("custom", {'message': "Hello world"}) + + @plugin.method("faulty-emit") + def faulty_emit(plugin): + """Emit a simple string notification to topic "custom" """ + plugin.notify("ididntannouncethis", {'message': "Hello world"}) + + plugin.add_notification_topic("custom") + + l1 = node_factory.get_node(inline_plugin=setup) + l2 = node_factory.get_node() + l3 = node_factory.get_node() + node_factory.join_nodes([l1, l2, l3], wait_for_announce=True) scid12 = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id'] scid12_dir = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['direction'] @@ -837,9 +873,8 @@ def zero_fields(obj, fieldnames): inv1 = l3.rpc.invoice(5000000, 'test_attempt_notifications1', 'test_attempt_notifications1') l1.rpc.xpay(inv1['bolt11']) - line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_start: ") - dict_str = line.split("Got pay_part_start: ", 1)[1] - data = zero_fields(ast.literal_eval(dict_str), ['groupid']) + wait_for(lambda: len(part_starts) >= 1) + data = zero_fields(part_starts.pop(0), ['groupid']) expected = {'pay_part_start': {'payment_hash': inv1['payment_hash'], 'groupid': 0, @@ -858,9 +893,8 @@ def zero_fields(obj, fieldnames): 'channel_out_msat': 5000000}]}} assert data == expected - line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_end: ") - dict_str = line.split("Got pay_part_end: ", 1)[1] - data = zero_fields(ast.literal_eval(dict_str), ('duration', 'groupid')) + wait_for(lambda: len(part_ends) >= 1) + data = zero_fields(part_ends.pop(0), ('duration', 'groupid')) expected = {'pay_part_end': {'payment_hash': inv1['payment_hash'], 'status': 'success', @@ -876,9 +910,8 @@ def zero_fields(obj, fieldnames): with pytest.raises(RpcError, match=r"Destination said it doesn't know invoice: incorrect_or_unknown_payment_details"): l1.rpc.xpay(inv2['bolt11']) - line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_start: ") - dict_str = line.split("Got pay_part_start: ", 1)[1] - data = zero_fields(ast.literal_eval(dict_str), ['groupid']) + wait_for(lambda: len(part_starts) >= 1) + data = zero_fields(part_starts.pop(0), ['groupid']) expected = {'pay_part_start': {'payment_hash': inv2['payment_hash'], 'groupid': 0, @@ -897,9 +930,8 @@ def zero_fields(obj, fieldnames): 'channel_out_msat': 10000000}]}} assert data == expected - line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_end: ") - dict_str = line.split("Got pay_part_end: ", 1)[1] - data = zero_fields(ast.literal_eval(dict_str), ('duration', 'groupid')) + wait_for(lambda: len(part_ends) >= 1) + data = zero_fields(part_ends.pop(0), ('duration', 'groupid')) expected = {'pay_part_end': {'payment_hash': inv2['payment_hash'], 'status': 'failure', @@ -917,9 +949,8 @@ def zero_fields(obj, fieldnames): with pytest.raises(RpcError, match=r"Failed after 1 attempts"): l1.rpc.xpay(inv2['bolt11']) - line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_start: ") - dict_str = line.split("Got pay_part_start: ", 1)[1] - data = zero_fields(ast.literal_eval(dict_str), ['groupid']) + wait_for(lambda: len(part_starts) >= 1) + data = zero_fields(part_starts.pop(0), ['groupid']) expected = {'pay_part_start': {'payment_hash': inv2['payment_hash'], 'groupid': 0, @@ -938,9 +969,8 @@ def zero_fields(obj, fieldnames): 'channel_out_msat': 10000000}]}} assert data == expected - line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_end: ") - dict_str = line.split("Got pay_part_end: ", 1)[1] - data = zero_fields(ast.literal_eval(dict_str), ('duration', 'groupid', 'failed_msg')) + wait_for(lambda: len(part_ends) >= 1) + data = zero_fields(part_ends.pop(0), ('duration', 'groupid', 'failed_msg')) expected = {'pay_part_end': {'payment_hash': inv2['payment_hash'], 'status': 'failure', From 06fdca42a46cf7de1904bdf409d8c50737e6b976 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Fri, 15 May 2026 14:32:42 +0930 Subject: [PATCH 3/3] pytest: use l1/l2 naming and get_nodes() where appropriate. Replace non-standard node variable names (n, n2, node, ln) with l1/l2 across test_plugin.py and test_misc.py. Convert four tests in test_connection.py to use get_nodes() instead of consecutive get_node() calls with identical options (slightly faster). Co-Authored-By: Claude Sonnet 4.6 --- tests/test_connection.py | 12 +- tests/test_misc.py | 28 ++-- tests/test_plugin.py | 326 +++++++++++++++++++-------------------- 3 files changed, 181 insertions(+), 185 deletions(-) diff --git a/tests/test_connection.py b/tests/test_connection.py index 548c24ee36cd..b65b5956efd2 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -257,8 +257,7 @@ def test_connect_standard_addr(node_factory): def test_reconnect_channel_peers(node_factory, executor): - l1 = node_factory.get_node(may_reconnect=True) - l2 = node_factory.get_node(may_reconnect=True) + l1, l2 = node_factory.get_nodes(2, {'may_reconnect': True}) l1.rpc.connect(l2.info['id'], 'localhost', l2.port) l1.fundchannel(l2, 10**6) @@ -1202,8 +1201,7 @@ def test_funding_push(node_factory, bitcoind, chainparams): # We track balances, to verify that accounting is ok. coin_mvt_plugin = os.path.join(os.getcwd(), 'tests/plugins/coin_movements.py') - l1 = node_factory.get_node(options={'plugin': coin_mvt_plugin}) - l2 = node_factory.get_node(options={'plugin': coin_mvt_plugin}) + l1, l2 = node_factory.get_nodes(2, {'plugin': coin_mvt_plugin}) l1.rpc.connect(l2.info['id'], 'localhost', l2.port) @@ -2677,8 +2675,7 @@ def test_update_fee_reconnect(node_factory, bitcoind): def test_multiple_channels(node_factory): - l1 = node_factory.get_node() - l2 = node_factory.get_node() + l1, l2 = node_factory.get_nodes(2) ret = l1.rpc.connect(l2.info['id'], 'localhost', l2.port) assert ret['id'] == l2.info['id'] @@ -2705,8 +2702,7 @@ def test_multiple_channels(node_factory): @pytest.mark.openchannel('v1') @pytest.mark.openchannel('v2') def test_forget_channel(node_factory): - l1 = node_factory.get_node() - l2 = node_factory.get_node() + l1, l2 = node_factory.get_nodes(2) l1.fundwallet(10**6) l1.rpc.connect(l2.info['id'], 'localhost', l2.port) l1.rpc.fundchannel(l2.info['id'], 10**5) diff --git a/tests/test_misc.py b/tests/test_misc.py index 560b53fbe5ac..98432502de70 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -2868,11 +2868,11 @@ def test_new_node_is_mainnet(node_factory): def test_unicode_rpc(node_factory, executor, bitcoind): - node = node_factory.get_node() + l1 = node_factory.get_node() desc = "Some candy 🍬 and a nice glass of milk 🥛." - node.rpc.invoice(amount_msat=42, label=desc, description=desc) - invoices = node.rpc.listinvoices()['invoices'] + l1.rpc.invoice(amount_msat=42, label=desc, description=desc) + invoices = l1.rpc.listinvoices()['invoices'] assert(len(invoices) == 1) assert(invoices[0]['description'] == desc) assert(invoices[0]['label'] == desc) @@ -2897,29 +2897,29 @@ def test_unix_socket_path_length(node_factory, bitcoind, directory, executor, db def test_waitblockheight(node_factory, executor, bitcoind): - node = node_factory.get_node() + l1 = node_factory.get_node() - sync_blockheight(bitcoind, [node]) + sync_blockheight(bitcoind, [l1]) - blockheight = node.rpc.getinfo()['blockheight'] + blockheight = l1.rpc.getinfo()['blockheight'] # Should succeed without waiting. - node.rpc.waitblockheight(blockheight - 2) - node.rpc.waitblockheight(blockheight - 1) - node.rpc.waitblockheight(blockheight) + l1.rpc.waitblockheight(blockheight - 2) + l1.rpc.waitblockheight(blockheight - 1) + l1.rpc.waitblockheight(blockheight) # Developer mode polls bitcoind every second, so 60 seconds is plenty. time = 60 # Should not succeed yet. - fut2 = executor.submit(node.rpc.waitblockheight, blockheight + 2, time) - fut1 = executor.submit(node.rpc.waitblockheight, blockheight + 1, time) + fut2 = executor.submit(l1.rpc.waitblockheight, blockheight + 2, time) + fut1 = executor.submit(l1.rpc.waitblockheight, blockheight + 1, time) assert not fut1.done() assert not fut2.done() # Should take about ~1second and time out. with pytest.raises(RpcError): - node.rpc.waitblockheight(blockheight + 2, 1) + l1.rpc.waitblockheight(blockheight + 2, 1) # Others should still not be done. assert not fut1.done() @@ -2927,13 +2927,13 @@ def test_waitblockheight(node_factory, executor, bitcoind): # Trigger just one more block. bitcoind.generate_block(1) - sync_blockheight(bitcoind, [node]) + sync_blockheight(bitcoind, [l1]) fut1.result(5) assert not fut2.done() # Trigger two blocks. bitcoind.generate_block(1) - sync_blockheight(bitcoind, [node]) + sync_blockheight(bitcoind, [l1]) fut2.result(5) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 5c9565c4fa30..c602ff30e66d 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -75,8 +75,8 @@ def test_option_passthrough(node_factory, directory): # Now try to see if it gets accepted, would fail to start if the # option didn't exist - n = node_factory.get_node(options={'plugin': plugin_path, 'greeting': 'Ciao'}) - n.stop() + l1 = node_factory.get_node(options={'plugin': plugin_path, 'greeting': 'Ciao'}) + l1.stop() with pytest.raises(subprocess.CalledProcessError): err_out = subprocess.run([ @@ -96,34 +96,34 @@ def test_option_types(node_factory): respected in output """ plugin_path = os.path.join(os.getcwd(), 'tests/plugins/options.py') - n = node_factory.get_node(options={ + l1 = node_factory.get_node(options={ 'plugin': plugin_path, 'str_opt': 'ok', 'int_opt': 22, 'bool_opt': True, }) - assert n.daemon.is_in_log(r"option str_opt ok ") - assert n.daemon.is_in_log(r"option int_opt 22 ") - assert n.daemon.is_in_log(r"option bool_opt True ") + assert l1.daemon.is_in_log(r"option str_opt ok ") + assert l1.daemon.is_in_log(r"option int_opt 22 ") + assert l1.daemon.is_in_log(r"option bool_opt True ") # flag options aren't passed through if not flagged on - assert not n.daemon.is_in_log(r"option flag_opt") - n.stop() + assert not l1.daemon.is_in_log(r"option flag_opt") + l1.stop() # A blank bool_opt should default to false - n = node_factory.get_node(options={ + l1 = node_factory.get_node(options={ 'plugin': plugin_path, 'str_opt': 'ok', 'int_opt': 22, 'bool_opt': 'true', 'flag_opt': None, }) - assert n.daemon.is_in_log(r"option bool_opt True ") - assert n.daemon.is_in_log(r"option flag_opt True ") - n.stop() + assert l1.daemon.is_in_log(r"option bool_opt True ") + assert l1.daemon.is_in_log(r"option flag_opt True ") + l1.stop() # What happens if we give it a bad bool-option? - n = node_factory.get_node(options={ + l1 = node_factory.get_node(options={ 'plugin': plugin_path, 'str_opt': 'ok', 'int_opt': 22, @@ -131,12 +131,12 @@ def test_option_types(node_factory): }, may_fail=True, start=False) # the node should fail after start, and we get a stderr msg - n.daemon.start(wait_for_initialized=False, stderr_redir=True) - assert n.daemon.wait() == 1 - wait_for(lambda: n.daemon.is_in_stderr("--bool_opt=!: Invalid argument '!'")) + l1.daemon.start(wait_for_initialized=False, stderr_redir=True) + assert l1.daemon.wait() == 1 + wait_for(lambda: l1.daemon.is_in_stderr("--bool_opt=!: Invalid argument '!'")) # What happens if we give it a bad int-option? - n = node_factory.get_node(options={ + l1 = node_factory.get_node(options={ 'plugin': plugin_path, 'str_opt': 'ok', 'int_opt': 'notok', @@ -144,24 +144,24 @@ def test_option_types(node_factory): }, may_fail=True, start=False) # the node should fail after start, and we get a stderr msg - n.daemon.start(wait_for_initialized=False, stderr_redir=True) - assert n.daemon.wait() == 1 - assert n.daemon.is_in_stderr("--int_opt=notok: 'notok' is not a number") + l1.daemon.start(wait_for_initialized=False, stderr_redir=True) + assert l1.daemon.wait() == 1 + assert l1.daemon.is_in_stderr("--int_opt=notok: 'notok' is not a number") # We no longer allow '1' or '0' as boolean options - n = node_factory.get_node(options={ + l1 = node_factory.get_node(options={ 'plugin': plugin_path, 'str_opt': 'ok', 'bool_opt': '1', }, may_fail=True, start=False) # the node should fail after start, and we get a stderr msg - n.daemon.start(wait_for_initialized=False, stderr_redir=True) - assert n.daemon.wait() == 1 - assert n.daemon.is_in_stderr("--bool_opt=1: Invalid argument '1'") + l1.daemon.start(wait_for_initialized=False, stderr_redir=True) + assert l1.daemon.wait() == 1 + assert l1.daemon.is_in_stderr("--bool_opt=1: Invalid argument '1'") # Flag opts shouldn't allow any input - n = node_factory.get_node(options={ + l1 = node_factory.get_node(options={ 'plugin': plugin_path, 'str_opt': 'ok', 'int_opt': 11, @@ -170,33 +170,33 @@ def test_option_types(node_factory): }, may_fail=True, start=False) # the node should fail after start, and we get a stderr msg - n.daemon.start(wait_for_initialized=False, stderr_redir=True) - assert n.daemon.wait() == 1 - assert n.daemon.is_in_stderr("--flag_opt=True: doesn't allow an argument") + l1.daemon.start(wait_for_initialized=False, stderr_redir=True) + assert l1.daemon.wait() == 1 + assert l1.daemon.is_in_stderr("--flag_opt=True: doesn't allow an argument") - n = node_factory.get_node(options={ + l1 = node_factory.get_node(options={ 'plugin': plugin_path, 'str_optm': ['ok', 'ok2'], 'int_optm': [11, 12, 13], }) - assert n.daemon.is_in_log(r"option str_optm \['ok', 'ok2'\] ") - assert n.daemon.is_in_log(r"option int_optm \[11, 12, 13\] ") - n.stop() + assert l1.daemon.is_in_log(r"option str_optm \['ok', 'ok2'\] ") + assert l1.daemon.is_in_log(r"option int_optm \[11, 12, 13\] ") + l1.stop() def test_millisatoshi_passthrough(node_factory): """ Ensure that Millisatoshi arguments and return work. """ plugin_path = os.path.join(os.getcwd(), 'tests/plugins/millisatoshis.py') - n = node_factory.get_node(options={'plugin': plugin_path, 'log-level': 'io'}) + l1 = node_factory.get_node(options={'plugin': plugin_path, 'log-level': 'io'}) # By keyword (plugin literally returns Millisatoshi, which becomes a string) - ret = n.rpc.call('echo', {'msat': Millisatoshi(17), 'not_an_msat': '22msat'})['echo_msat'] + ret = l1.rpc.call('echo', {'msat': Millisatoshi(17), 'not_an_msat': '22msat'})['echo_msat'] assert Millisatoshi(ret) == Millisatoshi(17) # By position - ret = n.rpc.call('echo', [Millisatoshi(18), '22msat'])['echo_msat'] + ret = l1.rpc.call('echo', [Millisatoshi(18), '22msat'])['echo_msat'] assert Millisatoshi(ret) == Millisatoshi(18) @@ -208,29 +208,29 @@ def test_rpc_passthrough(node_factory): """ plugin_path = os.path.join(os.getcwd(), 'contrib/plugins/helloworld.py') - n = node_factory.get_node(options={'plugin': plugin_path, 'greeting': 'Ciao'}) + l1 = node_factory.get_node(options={'plugin': plugin_path, 'greeting': 'Ciao'}) # Make sure that the 'hello' command that the helloworld.py plugin # has registered is available. - cmd = [hlp for hlp in n.rpc.help()['help'] if 'hello' in hlp['command']] + cmd = [hlp for hlp in l1.rpc.help()['help'] if 'hello' in hlp['command']] assert(len(cmd) == 1) # Make sure usage message is present. - assert only_one(n.rpc.help('hello')['help'])['command'].startswith('hello [name]') + assert only_one(l1.rpc.help('hello')['help'])['command'].startswith('hello [name]') # While we're at it, let's check that helloworld.py is logging # correctly via the notifications plugin->lightningd - assert n.daemon.is_in_log('Plugin helloworld.py initialized') + assert l1.daemon.is_in_log('Plugin helloworld.py initialized') # Now try to call it and see what it returns: - greet = n.rpc.hello(name='World') + greet = l1.rpc.hello(name='World') assert(greet == "Ciao World") with pytest.raises(RpcError): - n.rpc.fail() + l1.rpc.fail() # Try to call a method without enough arguments with pytest.raises(RpcError, match="processing bye: missing a required" " argument"): - n.rpc.bye() + l1.rpc.bye() def test_plugin_dir(node_factory): @@ -242,91 +242,91 @@ def test_plugin_dir(node_factory): def test_plugin_slowinit(node_factory): """Tests that the 'plugin' RPC command times out if plugin doesnt respond""" os.environ['SLOWINIT_TIME'] = '121' - n = node_factory.get_node() + l1 = node_factory.get_node() with pytest.raises(RpcError, match=': timed out before replying to init'): - n.rpc.plugin_start(os.path.join(os.getcwd(), "tests/plugins/slow_init.py")) + l1.rpc.plugin_start(os.path.join(os.getcwd(), "tests/plugins/slow_init.py")) # It's not actually configured yet, see what happens; # make sure 'rescan' and 'list' controls dont crash - n.rpc.plugin_rescan() - n.rpc.plugin_list() + l1.rpc.plugin_rescan() + l1.rpc.plugin_list() def test_plugin_command(node_factory): """Tests the 'plugin' RPC command""" - n = node_factory.get_node() + l1 = node_factory.get_node() # Make sure that the 'hello' command from the helloworld.py plugin # is not available. - cmd = [hlp for hlp in n.rpc.help()["help"] if "hello" in hlp["command"]] + cmd = [hlp for hlp in l1.rpc.help()["help"] if "hello" in hlp["command"]] assert(len(cmd) == 0) # Add the 'contrib/plugins' test dir - n.rpc.plugin_startdir(directory=os.path.join(os.getcwd(), "contrib/plugins")) + l1.rpc.plugin_startdir(directory=os.path.join(os.getcwd(), "contrib/plugins")) # Make sure that the 'hello' command from the helloworld.py plugin # is now available. - cmd = [hlp for hlp in n.rpc.help()["help"] if "hello" in hlp["command"]] + cmd = [hlp for hlp in l1.rpc.help()["help"] if "hello" in hlp["command"]] assert(len(cmd) == 1) # Make sure 'rescan' and 'list' subcommands dont crash - n.rpc.plugin_rescan() - n.rpc.plugin_list() + l1.rpc.plugin_rescan() + l1.rpc.plugin_list() # Make sure the plugin behaves normally after stop and restart assert("Successfully stopped helloworld.py." - == n.rpc.plugin_stop(plugin="helloworld.py")["result"]) - n.daemon.wait_for_log(r"Killing plugin: stopped by lightningd via RPC") - n.rpc.plugin_start(plugin=os.path.join(os.getcwd(), "contrib/plugins/helloworld.py")) - n.daemon.wait_for_log(r"Plugin helloworld.py initialized") - assert("Hello world" == n.rpc.call(method="hello")) + == l1.rpc.plugin_stop(plugin="helloworld.py")["result"]) + l1.daemon.wait_for_log(r"Killing plugin: stopped by lightningd via RPC") + l1.rpc.plugin_start(plugin=os.path.join(os.getcwd(), "contrib/plugins/helloworld.py")) + l1.daemon.wait_for_log(r"Plugin helloworld.py initialized") + assert("Hello world" == l1.rpc.call(method="hello")) # Now stop the helloworld plugin assert("Successfully stopped helloworld.py." - == n.rpc.plugin_stop(plugin="helloworld.py")["result"]) - n.daemon.wait_for_log(r"Killing plugin: stopped by lightningd via RPC") + == l1.rpc.plugin_stop(plugin="helloworld.py")["result"]) + l1.daemon.wait_for_log(r"Killing plugin: stopped by lightningd via RPC") # Make sure that the 'hello' command from the helloworld.py plugin # is not available anymore. - cmd = [hlp for hlp in n.rpc.help()["help"] if "hello" in hlp["command"]] + cmd = [hlp for hlp in l1.rpc.help()["help"] if "hello" in hlp["command"]] assert(len(cmd) == 0) # Test that we cannot start a plugin with 'dynamic' set to False in # getmanifest with pytest.raises(RpcError, match=r"Not a dynamic plugin"): - n.rpc.plugin_start(plugin=os.path.join(os.getcwd(), "tests/plugins/static.py")) + l1.rpc.plugin_start(plugin=os.path.join(os.getcwd(), "tests/plugins/static.py")) # Test that we cannot stop a started plugin with 'dynamic' flag set to # False - n2 = node_factory.get_node(options={ + l2 = node_factory.get_node(options={ "plugin": os.path.join(os.getcwd(), "tests/plugins/static.py") }) with pytest.raises(RpcError, match=r"static.py cannot be managed when lightningd is up"): - n2.rpc.plugin_stop(plugin="static.py") + l2.rpc.plugin_stop(plugin="static.py") # Test that we don't crash when starting a broken plugin with pytest.raises(RpcError, match=r": exited before replying to getmanifest"): - n2.rpc.plugin_start(plugin=os.path.join(os.getcwd(), "tests/plugins/broken.py")) + l2.rpc.plugin_start(plugin=os.path.join(os.getcwd(), "tests/plugins/broken.py")) with pytest.raises(RpcError, match=r': timed out before replying to getmanifest'): - n2.rpc.plugin_start(os.path.join(os.getcwd(), 'contrib/plugins/fail/failtimeout.py')) + l2.rpc.plugin_start(os.path.join(os.getcwd(), 'contrib/plugins/fail/failtimeout.py')) # Test that we can add a directory with more than one new plugin in it. try: - n.rpc.plugin_startdir(os.path.join(os.getcwd(), "contrib/plugins")) + l1.rpc.plugin_startdir(os.path.join(os.getcwd(), "contrib/plugins")) except RpcError: pass # Usually, it crashes after the above return. - n.rpc.stop() + l1.rpc.stop() def test_plugin_fail_on_startup(node_factory): for crash in ('during_init', 'before_start', 'during_getmanifest'): os.environ['BROKEN_CRASH'] = crash - n = node_factory.get_node(options={'plugin': os.path.join(os.getcwd(), "tests/plugins/broken.py")}) + l1 = node_factory.get_node(options={'plugin': os.path.join(os.getcwd(), "tests/plugins/broken.py")}) # This can happen before 'Server started with public key' msg - n.daemon.logsearch_start = 0 - n.daemon.wait_for_log('plugin-broken.py: Traceback') + l1.daemon.logsearch_start = 0 + l1.daemon.wait_for_log('plugin-broken.py: Traceback') # Make sure they don't die *after* the message! time.sleep(30) @@ -336,64 +336,64 @@ def test_plugin_disable(node_factory): """--disable-plugin works""" plugin_dir = os.path.join(os.getcwd(), 'contrib/plugins') # We used to need plugin-dir before disable-plugin! - n = node_factory.get_node(options=OrderedDict([('plugin-dir', plugin_dir), - ('disable-plugin', - '{}/helloworld.py' - .format(plugin_dir))])) + l1 = node_factory.get_node(options=OrderedDict([('plugin-dir', plugin_dir), + ('disable-plugin', + '{}/helloworld.py' + .format(plugin_dir))])) with pytest.raises(RpcError): - n.rpc.hello(name='Sun') - assert n.daemon.is_in_log('helloworld.py: disabled via disable-plugin') - n.stop() + l1.rpc.hello(name='Sun') + assert l1.daemon.is_in_log('helloworld.py: disabled via disable-plugin') + l1.stop() # Also works by basename. - n = node_factory.get_node(options=OrderedDict([('plugin-dir', plugin_dir), - ('disable-plugin', - 'helloworld.py')])) + l1 = node_factory.get_node(options=OrderedDict([('plugin-dir', plugin_dir), + ('disable-plugin', + 'helloworld.py')])) with pytest.raises(RpcError): - n.rpc.hello(name='Sun') - assert n.daemon.is_in_log('helloworld.py: disabled via disable-plugin') - n.stop() + l1.rpc.hello(name='Sun') + assert l1.daemon.is_in_log('helloworld.py: disabled via disable-plugin') + l1.stop() # Other order also works! - n = node_factory.get_node(options=OrderedDict([('disable-plugin', - 'helloworld.py'), - ('plugin-dir', plugin_dir)])) + l1 = node_factory.get_node(options=OrderedDict([('disable-plugin', + 'helloworld.py'), + ('plugin-dir', plugin_dir)])) with pytest.raises(RpcError): - n.rpc.hello(name='Sun') - assert n.daemon.is_in_log('helloworld.py: disabled via disable-plugin') - n.stop() + l1.rpc.hello(name='Sun') + assert l1.daemon.is_in_log('helloworld.py: disabled via disable-plugin') + l1.stop() # Both orders of explicit specification work. - n = node_factory.get_node(options=OrderedDict([('disable-plugin', - 'helloworld.py'), - ('plugin', - '{}/helloworld.py' - .format(plugin_dir))])) + l1 = node_factory.get_node(options=OrderedDict([('disable-plugin', + 'helloworld.py'), + ('plugin', + '{}/helloworld.py' + .format(plugin_dir))])) with pytest.raises(RpcError): - n.rpc.hello(name='Sun') - assert n.daemon.is_in_log('helloworld.py: disabled via disable-plugin') - n.stop() + l1.rpc.hello(name='Sun') + assert l1.daemon.is_in_log('helloworld.py: disabled via disable-plugin') + l1.stop() # Both orders of explicit specification work. - n = node_factory.get_node(options=OrderedDict([('plugin', - '{}/helloworld.py' - .format(plugin_dir)), - ('disable-plugin', - 'helloworld.py')])) + l1 = node_factory.get_node(options=OrderedDict([('plugin', + '{}/helloworld.py' + .format(plugin_dir)), + ('disable-plugin', + 'helloworld.py')])) with pytest.raises(RpcError): - n.rpc.hello(name='Sun') - assert n.daemon.is_in_log('helloworld.py: disabled via disable-plugin') + l1.rpc.hello(name='Sun') + assert l1.daemon.is_in_log('helloworld.py: disabled via disable-plugin') # Still disabled if we load directory. - n.rpc.plugin_startdir(directory=os.path.join(os.getcwd(), "contrib/plugins")) - n.daemon.wait_for_log('helloworld.py: disabled via disable-plugin') - n.stop() + l1.rpc.plugin_startdir(directory=os.path.join(os.getcwd(), "contrib/plugins")) + l1.daemon.wait_for_log('helloworld.py: disabled via disable-plugin') + l1.stop() # Check that list works - n = node_factory.get_node(options={'disable-plugin': - ['something-else.py', 'helloworld.py']}) + l1 = node_factory.get_node(options={'disable-plugin': + ['something-else.py', 'helloworld.py']}) - assert n.rpc.listconfigs()['configs']['disable-plugin'] == {'values_str': ['something-else.py', 'helloworld.py'], 'sources': ['cmdline', 'cmdline']} + assert l1.rpc.listconfigs()['configs']['disable-plugin'] == {'values_str': ['something-else.py', 'helloworld.py'], 'sources': ['cmdline', 'cmdline']} def test_plugin_hook(node_factory, executor): @@ -2662,66 +2662,66 @@ def test_important_plugin(node_factory): # Cache it here. pluginsdir = os.path.join(os.path.dirname(__file__), "plugins") - n = node_factory.get_node(options={"important-plugin": os.path.join(pluginsdir, "nonexistent")}, - may_fail=True, expect_fail=True, - # Other plugins can complain as lightningd stops suddenly: - broken_log='Plugin marked as important, shutting down lightningd|Reading sync lightningd: Connection reset by peer|Lost connection to the RPC socket|Plugin terminated before replying to RPC call|plugin-cln-xpay: askrene-create-layer failed with.*Unknown command', - start=False) + l1 = node_factory.get_node(options={"important-plugin": os.path.join(pluginsdir, "nonexistent")}, + may_fail=True, expect_fail=True, + # Other plugins can complain as lightningd stops suddenly: + broken_log='Plugin marked as important, shutting down lightningd|Reading sync lightningd: Connection reset by peer|Lost connection to the RPC socket|Plugin terminated before replying to RPC call|plugin-cln-xpay: askrene-create-layer failed with.*Unknown command', + start=False) - n.daemon.start(wait_for_initialized=False, stderr_redir=True) + l1.daemon.start(wait_for_initialized=False, stderr_redir=True) # Will exit with failure code. - assert n.daemon.wait() == 1 - assert n.daemon.is_in_stderr(r"Failed to register .*nonexistent: No such file or directory") + assert l1.daemon.wait() == 1 + assert l1.daemon.is_in_stderr(r"Failed to register .*nonexistent: No such file or directory") # Check we exit if the important plugin dies. - n.daemon.opts['important-plugin'] = os.path.join(pluginsdir, "fail_by_itself.py") + l1.daemon.opts['important-plugin'] = os.path.join(pluginsdir, "fail_by_itself.py") - n.daemon.start(wait_for_initialized=False) + l1.daemon.start(wait_for_initialized=False) # Will exit with failure code. - assert n.daemon.wait() == 1 - n.daemon.wait_for_log(r'fail_by_itself.py: Plugin marked as important, shutting down lightningd') + assert l1.daemon.wait() == 1 + l1.daemon.wait_for_log(r'fail_by_itself.py: Plugin marked as important, shutting down lightningd') # Check if the important plugin is disabled, we run as normal. - n.daemon.opts['disable-plugin'] = "fail_by_itself.py" - n.daemon.start() + l1.daemon.opts['disable-plugin'] = "fail_by_itself.py" + l1.daemon.start() # Make sure we can call into a plugin RPC (this is from `bcli`) even # if fail_by_itself.py is disabled. - n.rpc.call("estimatefees", {}) - n.stop() + l1.rpc.call("estimatefees", {}) + l1.stop() # Check if an important plugin dies later, we fail. - del n.daemon.opts['disable-plugin'] - n.daemon.opts['important-plugin'] = os.path.join(pluginsdir, "suicidal_plugin.py") + del l1.daemon.opts['disable-plugin'] + l1.daemon.opts['important-plugin'] = os.path.join(pluginsdir, "suicidal_plugin.py") - n.start() + l1.start() with pytest.raises(RpcError): - n.rpc.call("die", {}) + l1.rpc.call("die", {}) # Should exit with exitcode 1 - n.daemon.wait_for_log('suicidal_plugin.py: Plugin marked as important, shutting down lightningd') - assert n.daemon.wait() == 1 - n.stop() + l1.daemon.wait_for_log('suicidal_plugin.py: Plugin marked as important, shutting down lightningd') + assert l1.daemon.wait() == 1 + l1.stop() # Check that if a builtin plugin dies, we fail. - start = n.daemon.logsearch_start - n.start() + start = l1.daemon.logsearch_start + l1.start() # Reset logsearch_start, since this will predate message that start() looks for. - n.daemon.logsearch_start = start - line = n.daemon.wait_for_log(r'.*started\([0-9]*\).*plugins/pay') + l1.daemon.logsearch_start = start + line = l1.daemon.wait_for_log(r'.*started\([0-9]*\).*plugins/pay') pidstr = re.search(r'.*started\(([0-9]*)\).*plugins/pay', line).group(1) # Kill pay. os.kill(int(pidstr), signal.SIGKILL) - n.daemon.wait_for_log('pay: Plugin marked as important, shutting down lightningd') + l1.daemon.wait_for_log('pay: Plugin marked as important, shutting down lightningd') # Should exit with exitcode 1 - assert n.daemon.wait() == 1 - n.stop() + assert l1.daemon.wait() == 1 + l1.stop() def test_dev_builtin_plugins_unimportant(node_factory): - n = node_factory.get_node(options={"dev-builtin-plugins-unimportant": None}) - n.rpc.plugin_stop(plugin="pay") + l1 = node_factory.get_node(options={"dev-builtin-plugins-unimportant": None}) + l1.rpc.plugin_stop(plugin="pay") def test_htlc_accepted_hook_crash(node_factory, executor): @@ -3101,11 +3101,11 @@ def init(options, configuration, plugin): """ # get a node that is not started so we can put a plugin in its lightning_dir - n = node_factory.get_node(start=False) - if "dev-no-plugin-checksum" in n.daemon.opts: - del n.daemon.opts["dev-no-plugin-checksum"] + l1 = node_factory.get_node(start=False) + if "dev-no-plugin-checksum" in l1.daemon.opts: + del l1.daemon.opts["dev-no-plugin-checksum"] - lndir = n.daemon.lightning_dir + lndir = l1.daemon.lightning_dir # write hello world plugin to lndir/plugins os.makedirs(os.path.join(lndir, 'plugins'), exist_ok=True) @@ -3115,13 +3115,13 @@ def init(options, configuration, plugin): os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC) # now fire up the node and wait for the plugin to print hello - n.daemon.start() - n.daemon.logsearch_start = 0 - n.daemon.wait_for_log(r"test_restart_on_update 1") + l1.daemon.start() + l1.daemon.logsearch_start = 0 + l1.daemon.wait_for_log(r"test_restart_on_update 1") # a rescan should not yet reload the plugin on the same file - n.rpc.plugin_rescan() - assert not n.daemon.is_in_log(r"Plugin changed, needs restart.") + l1.rpc.plugin_rescan() + assert not l1.daemon.is_in_log(r"Plugin changed, needs restart.") # modify the file with open(path, 'w+') as file: @@ -3129,10 +3129,10 @@ def init(options, configuration, plugin): os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC) # rescan and check - n.rpc.plugin_rescan() - n.daemon.wait_for_log(r"Plugin changed, needs restart.") - n.daemon.wait_for_log(r"test_restart_on_update 2") - n.stop() + l1.rpc.plugin_rescan() + l1.daemon.wait_for_log(r"Plugin changed, needs restart.") + l1.daemon.wait_for_log(r"test_restart_on_update 2") + l1.stop() def test_plugin_shutdown(node_factory): @@ -4616,25 +4616,25 @@ def test_all_subscription(node_factory, directory): def test_dynamic_option_python_plugin(node_factory): plugin = os.path.join(os.getcwd(), "tests/plugins/dynamic_option.py") - ln = node_factory.get_node(options={"plugin": plugin}) - result = ln.rpc.listconfigs("test-dynamic-config") + l1 = node_factory.get_node(options={"plugin": plugin}) + result = l1.rpc.listconfigs("test-dynamic-config") assert result["configs"]["test-dynamic-config"]["value_str"] == "initial" - assert ln.rpc.dynamic_option_report() == {'test-dynamic-config': 'initial'} - result = ln.rpc.setconfig("test-dynamic-config", "changed") + assert l1.rpc.dynamic_option_report() == {'test-dynamic-config': 'initial'} + result = l1.rpc.setconfig("test-dynamic-config", "changed") assert result["config"]["value_str"] == "changed" - assert ln.rpc.dynamic_option_report() == {'test-dynamic-config': 'changed'} + assert l1.rpc.dynamic_option_report() == {'test-dynamic-config': 'changed'} - ln.daemon.wait_for_log( + l1.daemon.wait_for_log( 'dynamic_option.py:.*Setting config test-dynamic-config to changed' ) with pytest.raises(RpcError, match="I don't like bad values!"): - ln.rpc.setconfig("test-dynamic-config", "bad value") + l1.rpc.setconfig("test-dynamic-config", "bad value") # Does not alter value! - assert ln.rpc.dynamic_option_report() == {'test-dynamic-config': 'changed'} + assert l1.rpc.dynamic_option_report() == {'test-dynamic-config': 'changed'} def test_renepay_not_important(node_factory):