pwn 0.5.776 → 0.5.778
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/Rakefile +1 -2
- data/build_gem.sh +14 -0
- data/documentation/General-PWN-Usage.md +16 -11
- data/documentation/Installation.md +21 -2
- data/documentation/pwn-REPL.md +9 -7
- data/etc/default_skills/pwn/plugins/repl/mesh/references/urls.md +1 -0
- data/lib/pwn/plugins/repl/mesh.rb +3484 -2713
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/module_skills_spec.rb +71 -32
- data/spec/lib/pwn/plugins/repl_mesh_console_spec.rb +2 -1
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +118 -0
- data/spec/lib/pwn/plugins/repl_mesh_multi_rx_spec.rb +3 -1
- data/spec/lib/pwn/plugins/repl_mesh_origin_gps_spec.rb +157 -15
- data/spec/lib/pwn/plugins/repl_mesh_scroll_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl_mesh_transport_label_spec.rb +11 -7
- data/third_party/pwn_rdoc.jsonl +3 -23
- metadata +5 -5
data/lib/pwn/version.rb
CHANGED
|
@@ -3,8 +3,46 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'tmpdir'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
describe PWN::ModuleSkills do
|
|
9
|
+
it 'saves legacy generated edits and merges without discarding unrelated edits' do
|
|
10
|
+
script = File.read(File.expand_path('../../../build_gem.sh', __dir__))
|
|
11
|
+
recovery = script.split('generated_skills=()', 2).last.split('git checkout master', 2).first
|
|
12
|
+
Dir.mktmpdir('pwn_upgrade') do |dir|
|
|
13
|
+
git = lambda do |*args|
|
|
14
|
+
out, status = Open3.capture2e('git', *args, chdir: dir)
|
|
15
|
+
expect(status.success?).to be(true), out
|
|
16
|
+
out
|
|
17
|
+
end
|
|
18
|
+
git.call('init', '-b', 'master')
|
|
19
|
+
git.call('config', 'user.email', 'test@example.invalid')
|
|
20
|
+
git.call('config', 'user.name', 'Upgrade Test')
|
|
21
|
+
skill = 'etc/default_skills/pwn/json_text/SKILL.md'
|
|
22
|
+
path = File.join(dir, skill)
|
|
23
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
24
|
+
original = "---\ngenerated: true\n---\noriginal\n"
|
|
25
|
+
File.write(path, original)
|
|
26
|
+
File.write(File.join(dir, 'notes'), 'original')
|
|
27
|
+
git.call('add', '.')
|
|
28
|
+
git.call('commit', '-m', 'base')
|
|
29
|
+
git.call('checkout', '-b', 'incoming')
|
|
30
|
+
File.write(path, "#{original}upstream\n")
|
|
31
|
+
git.call('commit', '-am', 'update')
|
|
32
|
+
git.call('checkout', 'master')
|
|
33
|
+
File.write(path, "#{original}local regeneration\n")
|
|
34
|
+
File.write(File.join(dir, 'notes'), 'keep my edit')
|
|
35
|
+
_, blocked = Open3.capture2e('git', 'merge', '--ff-only', 'incoming', chdir: dir)
|
|
36
|
+
expect(blocked.success?).to be(false)
|
|
37
|
+
out, status = Open3.capture2e('bash', '-ec', "generated_skills=()#{recovery}", chdir: dir)
|
|
38
|
+
expect(status.success?).to be(true), out
|
|
39
|
+
git.call('merge', '--ff-only', 'incoming')
|
|
40
|
+
expect(File.read(path)).to eq("#{original}upstream\n")
|
|
41
|
+
expect(File.read(File.join(dir, 'notes'))).to eq('keep my edit')
|
|
42
|
+
expect(git.call('show', "stash@{0}:#{skill}")).to include('local regeneration')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
8
46
|
it 'should display information for authors' do
|
|
9
47
|
expect(described_class).to respond_to :authors
|
|
10
48
|
end
|
|
@@ -23,48 +61,50 @@ describe PWN::ModuleSkills do
|
|
|
23
61
|
expect(described_class.relpath(source: 'pwn.rb')).to eq('pwn/SKILL.md')
|
|
24
62
|
end
|
|
25
63
|
|
|
26
|
-
it 'refreshes
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
64
|
+
it 'refreshes a temp skill tree and does not rewrite the checkout' do
|
|
65
|
+
checkout = File.join(PWN::ModuleSkills::GEM_SKILLS, 'json_text', 'SKILL.md')
|
|
66
|
+
before = File.read(checkout)
|
|
67
|
+
Dir.mktmpdir('pwn_mod_refresh') do |dir|
|
|
68
|
+
report = described_class.refresh!(dest: dir)
|
|
69
|
+
expect(report[:modules]).to be > 50
|
|
70
|
+
tb = File.join(dir, 'pwn', 'plugins', 'transparent_browser', 'SKILL.md')
|
|
71
|
+
expect(File.file?(tb)).to eq(true), tb
|
|
72
|
+
expect(File).not_to exist(File.join(dir, 'pwn', 'plugins', 'transparent_browser.md'))
|
|
73
|
+
body = File.read(tb)
|
|
74
|
+
expect(body).to include('PWN::Plugins::TransparentBrowser')
|
|
75
|
+
expect(body).to include('open')
|
|
76
|
+
expect(body).not_to match(/hermes/i)
|
|
36
77
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
78
|
+
pw = File.join(dir, 'pwn', 'sast', 'password', 'SKILL.md')
|
|
79
|
+
expect(File.file?(pw)).to eq(true), pw
|
|
80
|
+
expect(File.file?(File.join(dir, 'pwn', 'sast', 'password', 'references', 'security.md'))).to eq(true)
|
|
81
|
+
|
|
82
|
+
described_class.enumerate.each do |rec|
|
|
83
|
+
path = File.join(dir, described_class.relpath(source: rec[:source], const: rec[:const]))
|
|
84
|
+
expect(File.file?(path)).to eq(true), path
|
|
85
|
+
expect(File.read(path)).to include(rec[:const])
|
|
86
|
+
expect(File.read(path)).not_to match(/hermes/i)
|
|
87
|
+
end
|
|
88
|
+
drift = described_class.drift(dest: dir)
|
|
89
|
+
expect(drift[:missing]).to eq([])
|
|
90
|
+
expect(drift[:stale]).to eq([])
|
|
91
|
+
expect(drift[:orphans]).to eq([])
|
|
92
|
+
end
|
|
93
|
+
expect(File.read(checkout)).to eq(before)
|
|
40
94
|
|
|
41
95
|
gw = File.join(PWN::ModuleSkills::GEM_SKILLS, 'plugins', 'google_workspace', 'SKILL.md')
|
|
42
96
|
expect(File.read(gw)).to include('preserve: true')
|
|
43
97
|
expect(File.read(gw)).to include('gmail_search')
|
|
44
98
|
expect(File.read(gw)).to include('daily-brief.md')
|
|
45
99
|
expect(File).not_to exist(File.join(PWN::Config.default_skills_dir, 'productivity', 'google-workspace', 'SKILL.md'))
|
|
46
|
-
|
|
47
|
-
described_class.enumerate.each do |rec|
|
|
48
|
-
path = File.join(
|
|
49
|
-
File.dirname(PWN::ModuleSkills::GEM_SKILLS),
|
|
50
|
-
described_class.relpath(source: rec[:source], const: rec[:const])
|
|
51
|
-
)
|
|
52
|
-
expect(File.file?(path)).to eq(true), path
|
|
53
|
-
expect(File.read(path)).to include(rec[:const])
|
|
54
|
-
expect(File.read(path)).not_to match(/hermes/i)
|
|
55
|
-
end
|
|
56
|
-
drift = described_class.drift
|
|
57
|
-
expect(drift[:missing]).to eq([])
|
|
58
|
-
expect(drift[:stale]).to eq([])
|
|
59
|
-
expect(drift[:orphans]).to eq([])
|
|
60
100
|
end
|
|
61
101
|
|
|
62
|
-
it '
|
|
63
|
-
expect(described_class).to respond_to(:drift)
|
|
102
|
+
it 'does not rewrite the checkout before spec' do
|
|
64
103
|
rake = File.read(File.expand_path('../../../Rakefile', __dir__))
|
|
65
|
-
expect(rake).to match(/module_skills/)
|
|
104
|
+
expect(rake).to match(/task :module_skills do/)
|
|
66
105
|
expect(rake).to match(/ModuleSkills\.refresh!/)
|
|
67
|
-
expect(rake).
|
|
106
|
+
expect(rake).not_to match(/Task\[:spec\]\.enhance\(\[:module_skills\]\)/)
|
|
107
|
+
expect(rake).not_to match(/module_skills_drift/)
|
|
68
108
|
end
|
|
69
109
|
|
|
70
110
|
it 'install overwrites existing generated module skills' do
|
|
@@ -83,7 +123,6 @@ describe PWN::ModuleSkills do
|
|
|
83
123
|
end
|
|
84
124
|
|
|
85
125
|
it 'ships no credential-shaped artifacts under etc/default_skills' do
|
|
86
|
-
described_class.refresh!
|
|
87
126
|
rx = described_class.const_get(:ARTIFACT_RX)
|
|
88
127
|
leftovers = []
|
|
89
128
|
Dir.glob(File.join(PWN::Config.default_skills_dir, '**', '*')).each do |path|
|
|
@@ -116,7 +116,8 @@ describe PWN::Plugins::REPL, 'mesh terminal integration' do
|
|
|
116
116
|
end
|
|
117
117
|
expect(status.exitstatus).to eq(0), transcript
|
|
118
118
|
transcript.force_encoding(Encoding::UTF_8)
|
|
119
|
-
expect(transcript).to include('╭', '─', 'COMPOSE', 'CONVERSATION')
|
|
119
|
+
expect(transcript).to include('╭', '─', 'COMPOSE', 'CONVERSATION', 'SENDER', 'RECEIVER')
|
|
120
|
+
expect(transcript).not_to include('MSG DETAILS', 'SNDR INFO', 'RCVR INFO')
|
|
120
121
|
expect(File.read(File.join(dir, 'topic'))).to eq('msh/US/UT/2/e/LongFast/#')
|
|
121
122
|
path = File.join(dir, 'pwn.yaml')
|
|
122
123
|
expect(PWN::Plugins::Vault.file_encrypted?(file: path)).to be(true)
|
|
@@ -26,4 +26,122 @@ describe PWN::Plugins::REPL, 'mesh terminal layout' do
|
|
|
26
26
|
expect(layout[:tx_top]).to be < 10
|
|
27
27
|
expect(layout[:tx_top] + layout[:tx]).to eq(10)
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
it 'keeps a 32-column MSG DETAILS pane beside conversation when the terminal is wide enough' do
|
|
31
|
+
layout = described_class.send(:mesh_layout, lines: 24, cols: 110)
|
|
32
|
+
expect(layout[:details]).to eq(32)
|
|
33
|
+
expect(layout[:conversation]).to eq(78)
|
|
34
|
+
expect(layout[:header] + layout[:body] + layout[:tx]).to eq(24)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'omits MSG DETAILS when the terminal is too narrow for both panes' do
|
|
38
|
+
layout = described_class.send(:mesh_layout, lines: 24, cols: 40)
|
|
39
|
+
expect(layout[:details]).to eq(0)
|
|
40
|
+
expect(layout[:conversation]).to eq(40)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'resizes from the terminal dup the same way pwn-ai does' do
|
|
44
|
+
source = double('terminal dup', tty?: true, winsize: [30, 100])
|
|
45
|
+
stub_const('PWN::MeshTerm', source)
|
|
46
|
+
allow(Curses).to receive(:lines).and_return(24)
|
|
47
|
+
allow(Curses).to receive(:cols).and_return(80)
|
|
48
|
+
allow(Curses).to receive(:resizeterm)
|
|
49
|
+
allow(described_class).to receive(:mesh_relayout!)
|
|
50
|
+
_input, pipe = IO.pipe
|
|
51
|
+
original = $stdout
|
|
52
|
+
$stdout = pipe
|
|
53
|
+
described_class.send(:mesh_apply_resize)
|
|
54
|
+
expect(Curses).to have_received(:resizeterm).with(30, 100)
|
|
55
|
+
expect(described_class).to have_received(:mesh_relayout!).with(lines: 30, cols: 100)
|
|
56
|
+
ensure
|
|
57
|
+
$stdout = original
|
|
58
|
+
pipe&.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'does not read winsize when getch is a test proc' do
|
|
62
|
+
source = double('terminal dup', tty?: true)
|
|
63
|
+
allow(source).to receive(:winsize)
|
|
64
|
+
stub_const('PWN::MeshTerm', source)
|
|
65
|
+
described_class.send(:mesh_apply_resize, getch: proc {})
|
|
66
|
+
expect(source).not_to have_received(:winsize)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pane_double
|
|
70
|
+
win = double('pane')
|
|
71
|
+
allow(win).to receive(:resize)
|
|
72
|
+
allow(win).to receive(:move)
|
|
73
|
+
allow(win).to receive(:erase)
|
|
74
|
+
allow(win).to receive(:clear)
|
|
75
|
+
allow(win).to receive(:close)
|
|
76
|
+
allow(win).to receive(:maxx).and_return(40)
|
|
77
|
+
allow(win).to receive(:maxy).and_return(10)
|
|
78
|
+
%i[scrollok nodelay= keypad timeout= setpos addstr attron attroff refresh noutrefresh touch].each do |message|
|
|
79
|
+
allow(win).to receive(message)
|
|
80
|
+
end
|
|
81
|
+
win
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'keeps the same panes on resize and does not clear stdscr' do
|
|
85
|
+
old = pane_double
|
|
86
|
+
%i[MeshRxHeaderWin MeshRxFrameWin MeshRxBodyWin MeshSndrWin MeshRcvrWin MeshTxWin].each do |name|
|
|
87
|
+
stub_const("PWN::#{name}", old)
|
|
88
|
+
end
|
|
89
|
+
stub_const('PWN::MeshMutex', Mutex.new)
|
|
90
|
+
stub_const('PWN::MeshRxState', { rows: [{ header: '2026 · PEER · path · dest · RX via Serial', body: 'hello', color: 21 }], focus: :compose })
|
|
91
|
+
screen = double('stdscr', erase: nil, noutrefresh: nil)
|
|
92
|
+
allow(Curses).to receive(:stdscr).and_return(screen)
|
|
93
|
+
allow(Curses).to receive(:doupdate)
|
|
94
|
+
allow(Curses).to receive(:clear)
|
|
95
|
+
allow(Curses).to receive(:refresh)
|
|
96
|
+
allow(Curses).to receive(:color_pair).and_return(0)
|
|
97
|
+
described_class.send(:mesh_relayout!, lines: 30, cols: 120)
|
|
98
|
+
expect(old).to have_received(:resize).with(anything, 120).at_least(:once)
|
|
99
|
+
expect(old).not_to have_received(:close)
|
|
100
|
+
expect(PWN::MeshTxWin).to equal(old)
|
|
101
|
+
expect(Curses).not_to have_received(:clear)
|
|
102
|
+
expect(Curses).not_to have_received(:refresh)
|
|
103
|
+
expect(PWN::MeshRxBodyWin).to have_received(:addstr).with(a_string_including('RX via Serial'))
|
|
104
|
+
expect(PWN::MeshRxBodyWin).to have_received(:addstr).with(a_string_including('hello'))
|
|
105
|
+
expect(old).to have_received(:touch).at_least(4).times
|
|
106
|
+
expect(old).to have_received(:noutrefresh).at_least(4).times
|
|
107
|
+
expect(Curses).to have_received(:doupdate)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'does not raise when a resize arrives before any message exists' do
|
|
111
|
+
hide_const('PWN::MeshRxState')
|
|
112
|
+
old = pane_double
|
|
113
|
+
%i[MeshRxHeaderWin MeshRxFrameWin MeshRxBodyWin MeshTxWin].each do |name|
|
|
114
|
+
stub_const("PWN::#{name}", old)
|
|
115
|
+
end
|
|
116
|
+
stub_const('PWN::MeshMutex', Mutex.new)
|
|
117
|
+
allow(Curses).to receive(:color_pair).and_return(0)
|
|
118
|
+
allow(Curses).to receive(:stdscr).and_return(double('stdscr', erase: nil, noutrefresh: nil))
|
|
119
|
+
allow(Curses).to receive(:doupdate)
|
|
120
|
+
expect { described_class.send(:mesh_relayout!, lines: 24, cols: 100) }.not_to raise_error
|
|
121
|
+
expect(PWN::MeshTxWin).to equal(old)
|
|
122
|
+
expect(old).not_to have_received(:close)
|
|
123
|
+
ensure
|
|
124
|
+
%i[MeshSndrWin MeshRcvrWin].each do |name|
|
|
125
|
+
PWN.send(:remove_const, name) if PWN.const_defined?(name)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'drops side-pane focus when a resize hides SENDER and RECEIVER' do
|
|
130
|
+
old = pane_double
|
|
131
|
+
%i[MeshRxHeaderWin MeshRxFrameWin MeshRxBodyWin MeshSndrWin MeshRcvrWin MeshTxWin].each do |name|
|
|
132
|
+
stub_const("PWN::#{name}", old)
|
|
133
|
+
end
|
|
134
|
+
stub_const('PWN::MeshMutex', Mutex.new)
|
|
135
|
+
stub_const('PWN::MeshRxState', { focus: :sndr, rows: [] })
|
|
136
|
+
allow(Curses).to receive(:color_pair).and_return(0)
|
|
137
|
+
allow(Curses).to receive(:stdscr).and_return(double('stdscr', erase: nil, noutrefresh: nil))
|
|
138
|
+
allow(Curses).to receive(:doupdate)
|
|
139
|
+
described_class.send(:mesh_relayout!, lines: 24, cols: 40)
|
|
140
|
+
expect(PWN.const_defined?(:MeshSndrWin)).to be(false)
|
|
141
|
+
expect(described_class.send(:mesh_focus)).to eq(:conversation)
|
|
142
|
+
expect(PWN::MeshTxWin).to equal(old)
|
|
143
|
+
ensure
|
|
144
|
+
PWN.const_set(:MeshSndrWin, old) unless PWN.const_defined?(:MeshSndrWin)
|
|
145
|
+
PWN.const_set(:MeshRcvrWin, old) unless PWN.const_defined?(:MeshRcvrWin)
|
|
146
|
+
end
|
|
29
147
|
end
|
|
@@ -224,7 +224,9 @@ describe PWN::Plugins::REPL, 'additive MQTT receive ownership' do
|
|
|
224
224
|
stub_const('PWN::MeshObj', {})
|
|
225
225
|
stub_const('PWN::MeshTransport', :mqtt)
|
|
226
226
|
described_class.send(:mesh_handle_rx, env: env, msg: messages.first)
|
|
227
|
-
expect(win).to have_received(:addstr).with(a_string_including('FIRST', '/LongFast/'
|
|
227
|
+
expect(win).to have_received(:addstr).with(a_string_including('FIRST', '/LongFast/'))
|
|
228
|
+
expect(win).to have_received(:addstr).with(a_string_including('RX via Serial'))
|
|
229
|
+
expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
|
|
228
230
|
expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).with(hash_including(obj: radio, channel_name: 'LongFast'))
|
|
229
231
|
end
|
|
230
232
|
|
|
@@ -36,6 +36,39 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
|
|
|
36
36
|
end
|
|
37
37
|
end.new
|
|
38
38
|
end
|
|
39
|
+
let(:details) do
|
|
40
|
+
Class.new do
|
|
41
|
+
attr_reader :lines
|
|
42
|
+
attr_accessor :width
|
|
43
|
+
|
|
44
|
+
def initialize
|
|
45
|
+
@lines = []
|
|
46
|
+
@width = 32
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def maxx
|
|
50
|
+
@width
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def maxy
|
|
54
|
+
20
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def attron(*) end
|
|
58
|
+
def attroff(*) end
|
|
59
|
+
def refresh; end
|
|
60
|
+
def setpos(*); end
|
|
61
|
+
|
|
62
|
+
def addstr(text)
|
|
63
|
+
@lines << text
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear
|
|
67
|
+
@lines.clear
|
|
68
|
+
end
|
|
69
|
+
end.new
|
|
70
|
+
end
|
|
71
|
+
let(:rcvr) { details.class.new }
|
|
39
72
|
let(:packet) { { id: 123, from: 0x421f6f22, to: 0xffffffff, decoded: { portnum: :TEXT_MESSAGE_APP, payload: 'hello' } } }
|
|
40
73
|
|
|
41
74
|
before do
|
|
@@ -46,6 +79,8 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
|
|
|
46
79
|
stub_const('PWN::MeshLastTx', nil)
|
|
47
80
|
stub_const('PWN::MeshMutex', Mutex.new)
|
|
48
81
|
stub_const('PWN::MeshRxBodyWin', win)
|
|
82
|
+
stub_const('PWN::MeshSndrWin', details)
|
|
83
|
+
stub_const('PWN::MeshRcvrWin', rcvr)
|
|
49
84
|
allow(Curses).to receive(:color_pair) { |color| color }
|
|
50
85
|
allow(described_class).to receive(:mesh_env_hash).and_return(env)
|
|
51
86
|
allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
|
|
@@ -60,17 +95,30 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
|
|
|
60
95
|
described_class.send(:mesh_handle_rx, env: env, obj: broker, msg: { packet: packet, mesh_transport: :mqtt, topic: 'msh/US/2/e/LongFast/!421f6f22' })
|
|
61
96
|
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
|
|
62
97
|
|
|
63
|
-
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
98
|
+
painted = win.lines.join
|
|
99
|
+
expect(painted).to include('RX via Serial')
|
|
100
|
+
expect(painted).not_to include('RX via MQTT')
|
|
101
|
+
expect(rcvr.lines.join).not_to include('VIA')
|
|
102
|
+
expect(details.lines.join).not_to include('VIA')
|
|
66
103
|
expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).once.with(hash_including(transport: :mqtt))
|
|
67
104
|
end
|
|
68
105
|
|
|
106
|
+
it 'does not index an MQTT client while painting node details' do
|
|
107
|
+
require 'mqtt'
|
|
108
|
+
client = MQTT::Client.new(host: 'offline.invalid')
|
|
109
|
+
stub_const('PWN::MeshReceivers', [{ obj: client, transport: :mqtt, secondary: true, active: true }])
|
|
110
|
+
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
|
|
111
|
+
described_class.send(:mesh_handle_rx, env: env, obj: client, msg: { packet: packet.merge(id: 124), mesh_transport: :mqtt, topic: 'msh/US/2/e/LongFast/!421f6f22' })
|
|
112
|
+
expect(described_class).not_to have_received(:mesh_ui_puts).with(hash_including(text: a_string_including('RX display failed')))
|
|
113
|
+
expect(details.lines.join).to include('Node ID: !421f6f22')
|
|
114
|
+
end
|
|
115
|
+
|
|
69
116
|
it 'keeps RX via MQTT when that is the only observation' do
|
|
70
117
|
described_class.send(:mesh_handle_rx, env: env, obj: broker, msg: { packet: packet, mesh_transport: :mqtt, topic: 'msh/US/2/e/LongFast/!421f6f22' })
|
|
71
118
|
|
|
72
|
-
expect(
|
|
73
|
-
expect(
|
|
119
|
+
expect(win.lines.join).to include('RX via MQTT')
|
|
120
|
+
expect(win.lines.join).not_to include('RX via Serial')
|
|
121
|
+
expect(rcvr.lines.join).not_to include('VIA')
|
|
74
122
|
end
|
|
75
123
|
|
|
76
124
|
it 'publishes the configured channel sender instead of the MQTT library default' do
|
|
@@ -88,7 +136,7 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
|
|
|
88
136
|
described_class.send(:mesh_send_text, env: radio_env, obj: broker, transport: :mqtt, from: '!00000b0b', text: 'sent', channel_name: 'LongFast')
|
|
89
137
|
end
|
|
90
138
|
|
|
91
|
-
it 'links a known sender position and leaves unknown positions unlabeled' do
|
|
139
|
+
it 'links a known sender position in MSG DETAILS and leaves unknown positions unlabeled' do
|
|
92
140
|
lat_i = 10_000_000
|
|
93
141
|
lon_i = -20_000_000
|
|
94
142
|
coord = format('%<lat>.7f,%<lon>.7f', lat: lat_i / 10_000_000.0, lon: lon_i / 10_000_000.0)
|
|
@@ -96,20 +144,114 @@ describe PWN::Plugins::REPL, 'mesh primary display, MQTT sender, and GPS link' d
|
|
|
96
144
|
packet: { id: 1, from: 0x421f6f22, decoded: { portnum: :POSITION_APP, payload: { latitude_i: lat_i, longitude_i: lon_i, location_source: :LOC_INTERNAL } } }
|
|
97
145
|
})
|
|
98
146
|
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
|
|
99
|
-
linked =
|
|
100
|
-
expect(
|
|
147
|
+
linked = details.lines.join
|
|
148
|
+
expect(details.lines.join).to include('SENDER', 'Node ID: !421f6f22', coord)
|
|
149
|
+
expect(rcvr.lines.join).to include('RECEIVER', 'Node ID: LongFast', 'GPS: N/A')
|
|
150
|
+
expect(rcvr.lines.join).not_to include('broadcast')
|
|
151
|
+
expect(linked).not_to include('maps.google.com')
|
|
101
152
|
expect(linked).not_to include("\e")
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
allow(
|
|
105
|
-
|
|
106
|
-
expect(
|
|
153
|
+
hit = PWN::MeshRxState[:gps_hits].find { |row| row[:url].include?(coord) }
|
|
154
|
+
expect(hit[:url]).to eq("https://maps.google.com/?q=#{coord}")
|
|
155
|
+
allow(described_class).to receive(:mesh_maps_opener).and_return(['/usr/bin/xdg-open'])
|
|
156
|
+
expect(Process).to receive(:spawn).with('/usr/bin/xdg-open', hit[:url], hash_including(out: File::NULL, err: File::NULL)).and_return(0)
|
|
157
|
+
expect(Process).to receive(:detach).with(0)
|
|
158
|
+
described_class.send(:mesh_open_gps_at, x: hit[:x], y: hit[:y])
|
|
159
|
+
expect(details.lines.join).not_to include('maps.google.com')
|
|
160
|
+
expect(headers.join).not_to include('GPS N/A')
|
|
161
|
+
expect(headers.join).not_to include('maps.google.com')
|
|
162
|
+
details.lines.clear
|
|
163
|
+
allow(details).to receive(:maxx).and_return(20)
|
|
164
|
+
described_class.send(:mesh_paint_details, obj: radio)
|
|
165
|
+
expect(details.lines.grep(Regexp.new(Regexp.escape(coord.split(',').first)))).to all(include(coord))
|
|
107
166
|
|
|
108
167
|
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: {
|
|
109
168
|
packet: { id: 2, from: 0x99aabbcc, to: 0xffffffff, decoded: { portnum: 1, payload: 'no fix' } }, mesh_transport: :serial
|
|
110
169
|
})
|
|
111
|
-
expect(
|
|
112
|
-
expect(
|
|
170
|
+
expect(details.lines.join).to include('!99aabbcc', 'GPS: N/A')
|
|
171
|
+
expect(details.lines.join).not_to include('maps.google.com')
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'shows the highlighted conversation message, and the last received message while composing' do
|
|
175
|
+
first = '1.0000000,2.0000000'
|
|
176
|
+
second = '3.0000000,4.0000000'
|
|
177
|
+
described_class.send(:mesh_store_position, id: '!421f6f22', position: { latitude_i: 10_000_000, longitude_i: 20_000_000, location_source: :LOC_INTERNAL })
|
|
178
|
+
described_class.send(:mesh_store_position, id: '!99aabbcc', position: { latitude_i: 30_000_000, longitude_i: 40_000_000, location_source: :LOC_INTERNAL })
|
|
179
|
+
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
|
|
180
|
+
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: {
|
|
181
|
+
packet: packet.merge(id: 9, from: 0x99aabbcc), mesh_transport: :serial
|
|
182
|
+
})
|
|
183
|
+
expect(details.lines.join).to include(second)
|
|
184
|
+
expect(details.lines.join).not_to include(first)
|
|
185
|
+
allow(win).to receive(:maxy).and_return(1)
|
|
186
|
+
described_class.send(:mesh_set_focus, focus: :conversation)
|
|
187
|
+
described_class.send(:mesh_scroll_conversation, key: :home)
|
|
188
|
+
expect(details.lines.join).to include(first)
|
|
189
|
+
expect(details.lines.join).not_to include(second)
|
|
190
|
+
described_class.send(:mesh_set_focus, focus: :compose)
|
|
191
|
+
expect(details.lines.join).to include(second)
|
|
192
|
+
expect(details.lines.join).not_to include(first)
|
|
193
|
+
described_class.send(:mesh_handle_rx, env: env, obj: radio, local: true, transport: :serial, msg: {
|
|
194
|
+
packet: packet.merge(id: 11, from: 0x1337, to: 0x99aabbcc), decoded: { portnum: 1, payload: 'sent' }
|
|
195
|
+
})
|
|
196
|
+
expect(details.lines.join).to include(second)
|
|
197
|
+
expect(details.lines.join).not_to include('!00001337')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'includes the receiver fix for a direct message and not for broadcast' do
|
|
201
|
+
described_class.send(:mesh_store_position, id: '!421f6f22', position: { latitude_i: 10_000_000, longitude_i: -20_000_000, location_source: :LOC_INTERNAL })
|
|
202
|
+
described_class.send(:mesh_store_position, id: '!00001337', position: { latitude_i: 11_000_000, longitude_i: -21_000_000, location_source: :LOC_MANUAL })
|
|
203
|
+
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: {
|
|
204
|
+
packet: packet.merge(to: 0x1337), mesh_transport: :serial
|
|
205
|
+
})
|
|
206
|
+
sender = '1.0000000,-2.0000000'
|
|
207
|
+
receiver = '1.1000000,-2.1000000'
|
|
208
|
+
painted = details.lines.join
|
|
209
|
+
expect(details.lines.join).to include('SENDER', 'Node ID: !421f6f22', sender)
|
|
210
|
+
expect(rcvr.lines.join).to include('RECEIVER', 'Node ID: !00001337', receiver)
|
|
211
|
+
expect(rcvr.lines.join).not_to include('broadcast')
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'shows node inventory fields under cyan SENDER and RECEIVER titles' do
|
|
215
|
+
heard = Time.at(1_700_000_000).getlocal.strftime('%Y-%m-%d %H:%M:%S%z')
|
|
216
|
+
key = 'k' * 32
|
|
217
|
+
radio[:proto_data] << {
|
|
218
|
+
node_info: {
|
|
219
|
+
num: 0x421f6f22, last_heard: 1_700_000_000, hops_away: 3, is_key_manually_verified: true,
|
|
220
|
+
device_metrics: { uptime_seconds: 3661 },
|
|
221
|
+
user: { short_name: 'PEER', role: :ROUTER, public_key: key }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
colors = []
|
|
225
|
+
allow(details).to receive(:attron) { |attr| colors << attr }
|
|
226
|
+
described_class.send(:mesh_handle_rx, env: env, obj: radio, msg: { packet: packet, mesh_transport: :serial })
|
|
227
|
+
painted = details.lines.join("\n")
|
|
228
|
+
expect(painted).to include('SENDER', 'Node ID: !421f6f22', 'GPS:')
|
|
229
|
+
expect(painted).not_to include('VIA')
|
|
230
|
+
node_num = 0x421f6f22
|
|
231
|
+
expect(painted).to include('Short Name: PEER', 'Device Role: ROUTER', "Node #: #{node_num}")
|
|
232
|
+
expect(painted).to include('Last Heard:', heard, 'Hops Away: 3', 'Uptime: 1h 1m 1s')
|
|
233
|
+
expect(painted).to include('Security: PKI verified', 'Public Key:')
|
|
234
|
+
expect(painted.gsub(/\s+/, '')).to include(key.unpack1('H*'))
|
|
235
|
+
expect(colors).to include(Curses.color_pair(20) | Curses::A_BOLD)
|
|
236
|
+
expect(rcvr.lines.join).to include('RECEIVER', 'Node ID: LongFast', 'Short Name: —', 'Device Role: —')
|
|
237
|
+
expect(rcvr.lines.join).not_to include('VIA', 'PEER', 'broadcast')
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'does not refresh MSG DETAILS from the radio position cache' do
|
|
241
|
+
refreshes = 0
|
|
242
|
+
allow(details).to receive(:refresh) { refreshes += 1 }
|
|
243
|
+
20.times do |index|
|
|
244
|
+
described_class.send(:mesh_store_position, id: format('!%08x', index), position: { latitude_i: 10_000_000, longitude_i: -20_000_000, location_source: :LOC_INTERNAL })
|
|
245
|
+
end
|
|
246
|
+
expect(refreshes).to eq(0)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'does not warn when reading a curses mouse event' do
|
|
250
|
+
allow(Curses).to receive(:getmouse) do
|
|
251
|
+
warn 'undefining the allocator of T_DATA class Curses::MouseEvent'
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
expect { described_class.send(:mesh_open_gps_click) }.not_to output(/MouseEvent/).to_stderr
|
|
113
255
|
end
|
|
114
256
|
|
|
115
257
|
it 'treats unset 0,0 as unknown and an explicit source at 0,0 as a real fix' do
|
|
@@ -46,4 +46,47 @@ describe PWN::Plugins::REPL, 'mesh conversation scroll' do
|
|
|
46
46
|
described_class.send(:mesh_scroll_conversation, key: :down)
|
|
47
47
|
expect(PWN::MeshRxState[:scroll]).to be_nil
|
|
48
48
|
end
|
|
49
|
+
|
|
50
|
+
it 'moves between messages that already fit in the pane' do
|
|
51
|
+
stub_const('PWN::MeshRxState', {
|
|
52
|
+
lines: [
|
|
53
|
+
{ text: 'one', color: 21, bold: true, message: 0 },
|
|
54
|
+
{ text: 'two', color: 21, bold: true, message: 1 }
|
|
55
|
+
],
|
|
56
|
+
detail_rows: [
|
|
57
|
+
{ index: 0, from_id: '!00000001', to_id: '!ffffffff', received: true },
|
|
58
|
+
{ index: 1, from_id: '!00000002', to_id: '!ffffffff', received: true }
|
|
59
|
+
],
|
|
60
|
+
focus: :conversation
|
|
61
|
+
})
|
|
62
|
+
allow(PWN::MeshRxBodyWin).to receive(:maxy).and_return(20)
|
|
63
|
+
described_class.send(:mesh_scroll_conversation, key: :up)
|
|
64
|
+
expect(PWN::MeshRxState[:selected]).to eq(0)
|
|
65
|
+
expect(described_class.send(:mesh_highlighted_message)[:from_id]).to eq('!00000001')
|
|
66
|
+
described_class.send(:mesh_scroll_conversation, key: :down)
|
|
67
|
+
expect(PWN::MeshRxState[:selected]).to be_nil
|
|
68
|
+
expect(described_class.send(:mesh_highlighted_message)[:from_id]).to eq('!00000002')
|
|
69
|
+
described_class.send(:mesh_scroll_conversation, key: :home)
|
|
70
|
+
expect(PWN::MeshRxState[:selected]).to eq(0)
|
|
71
|
+
described_class.send(:mesh_scroll_conversation, key: :page_down)
|
|
72
|
+
expect(PWN::MeshRxState[:selected]).to be_nil
|
|
73
|
+
described_class.send(:mesh_scroll_conversation, key: :page_up)
|
|
74
|
+
expect(PWN::MeshRxState[:selected]).to eq(0)
|
|
75
|
+
described_class.send(:mesh_scroll_conversation, key: :end)
|
|
76
|
+
expect(PWN::MeshRxState[:selected]).to be_nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'cycles Ctrl+T through compose, conversation, sndr, and rcvr' do
|
|
80
|
+
stub_const('PWN::MeshSndrWin', double('sndr', setpos: nil, addstr: nil, attron: nil, attroff: nil, refresh: nil, maxx: 32, erase: nil, maxy: 6))
|
|
81
|
+
stub_const('PWN::MeshRcvrWin', double('rcvr', setpos: nil, addstr: nil, attron: nil, attroff: nil, refresh: nil, maxx: 32, erase: nil, maxy: 6))
|
|
82
|
+
allow(described_class).to receive(:mesh_paint_details)
|
|
83
|
+
described_class.send(:mesh_toggle_focus)
|
|
84
|
+
expect(described_class.send(:mesh_focus)).to eq(:conversation)
|
|
85
|
+
described_class.send(:mesh_toggle_focus)
|
|
86
|
+
expect(described_class.send(:mesh_focus)).to eq(:sndr)
|
|
87
|
+
described_class.send(:mesh_toggle_focus)
|
|
88
|
+
expect(described_class.send(:mesh_focus)).to eq(:rcvr)
|
|
89
|
+
described_class.send(:mesh_toggle_focus)
|
|
90
|
+
expect(described_class.send(:mesh_focus)).to eq(:compose)
|
|
91
|
+
end
|
|
49
92
|
end
|
|
@@ -52,7 +52,8 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
|
|
|
52
52
|
obj[:proto_data].last[:node_info][:user][:short_name] = 'LATE'
|
|
53
53
|
described_class.send(:mesh_drain_events)
|
|
54
54
|
|
|
55
|
-
expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · PEER · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via #{label}
|
|
55
|
+
expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · PEER · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via #{label}\n")
|
|
56
|
+
expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
|
|
56
57
|
expect(win).to have_received(:attron).with(21 | Curses::A_BOLD)
|
|
57
58
|
expect(win).to have_received(:addstr).with(" hello\n")
|
|
58
59
|
expect(message).not_to have_key(:mesh_transport)
|
|
@@ -75,7 +76,8 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
|
|
|
75
76
|
end
|
|
76
77
|
described_class.send(:mesh_send_text, env: env, obj: obj, text: 'sent', channel_name: 'LongFast', channel: 0)
|
|
77
78
|
|
|
78
|
-
expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · SELF · US/CA/2/e/LongFast/!aabbccdd (ME) · US/CA/2/e/LongFast/# · TX via #{label}
|
|
79
|
+
expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · SELF · US/CA/2/e/LongFast/!aabbccdd (ME) · US/CA/2/e/LongFast/# · TX via #{label}\n")
|
|
80
|
+
expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
|
|
79
81
|
expect(win).to have_received(:attron).with(23 | Curses::A_BOLD)
|
|
80
82
|
expect(described_class).not_to have_received(:mesh_maybe_dispatch_to_pwn_ai)
|
|
81
83
|
end
|
|
@@ -101,7 +103,7 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
|
|
|
101
103
|
described_class.send(:mesh_handle_rx, msg: message.merge(mesh_transport: :tcp))
|
|
102
104
|
reply = message[:packet].merge(id: 124, decoded: { portnum: 1, reply_id: 123, payload: '@ai reply' })
|
|
103
105
|
described_class.send(:mesh_handle_rx, msg: { packet: reply, mesh_transport: :tcp })
|
|
104
|
-
expect(win).to have_received(:addstr).with(a_string_matching(
|
|
106
|
+
expect(win).to have_received(:addstr).with(a_string_matching(/🔒 · PEER · .*!bbccddee.*!aabbccdd \(ME\) · RX via TCP\n/)).twice
|
|
105
107
|
expect(win).to have_received(:addstr).with(" Replied to: \"!bbccddee >> hello\" with: @ai reply\n")
|
|
106
108
|
expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).with(hash_including(text: '@ai reply', radio: 0))
|
|
107
109
|
end
|
|
@@ -109,7 +111,8 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
|
|
|
109
111
|
it 'does not invent a known transport for an untagged event' do
|
|
110
112
|
stub_const('PWN::MeshTransport', :mqtt)
|
|
111
113
|
described_class.send(:mesh_handle_rx, msg: message)
|
|
112
|
-
expect(win).to have_received(:addstr).with(
|
|
114
|
+
expect(win).to have_received(:addstr).with(a_string_including('RX via Unknown'))
|
|
115
|
+
expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
it 'learns MQTT sender names from real NODEINFO envelopes, not gateway or destination names' do
|
|
@@ -140,7 +143,7 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
|
|
|
140
143
|
it "omits absent/blank short names without substituting the long name: #{name.inspect}" do
|
|
141
144
|
obj[:proto_data].each { |row| row[:node_info][:user][:short_name] = name }
|
|
142
145
|
described_class.send(:mesh_handle_rx, msg: message.merge(mesh_transport: :serial))
|
|
143
|
-
expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via Serial
|
|
146
|
+
expect(win).to have_received(:addstr).with(" 2026-01-02 03:04:05+0000 🔍 · US/CA/2/e/LongFast/!bbccddee · US/CA/2/e/LongFast/# · RX via Serial\n")
|
|
144
147
|
end
|
|
145
148
|
end
|
|
146
149
|
|
|
@@ -188,9 +191,10 @@ describe PWN::Plugins::REPL, 'mesh per-message transport labels' do
|
|
|
188
191
|
allow(win).to receive(:addstr) { |text| lines << text }
|
|
189
192
|
described_class.send(:mesh_handle_rx, msg: message.merge(mesh_transport: :serial))
|
|
190
193
|
header_lines = lines.take_while { |text| !text.include?('hello') }.map(&:strip).reject(&:empty?)
|
|
191
|
-
expect(header_lines.join(' · ').split(' · ').size).to eq(
|
|
194
|
+
expect(header_lines.join(' · ').split(' · ').size).to eq(5)
|
|
192
195
|
expect(header_lines.join("\n")).not_to match(/ ·|· /)
|
|
193
|
-
expect(header_lines.join("\n")).to include('US/CA/2/e/LongFast/!bbccddee', 'US/CA/2/e/LongFast/#', 'RX via Serial'
|
|
196
|
+
expect(header_lines.join("\n")).to include('US/CA/2/e/LongFast/!bbccddee', 'US/CA/2/e/LongFast/#', 'RX via Serial')
|
|
197
|
+
expect(described_class.send(:mesh_details_lines, message: PWN::MeshRxState[:detail_rows].last, width: 28).values.flatten.map { |line| line[:text] }.join("\n")).not_to include('VIA')
|
|
194
198
|
end
|
|
195
199
|
end
|
|
196
200
|
|