appear 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,183 @@
1
+ require 'appear/service'
2
+ require 'appear/join'
3
+
4
+ module Appear
5
+ # stores all the ways we can appear something
6
+ REVEALERS = []
7
+
8
+ # The Revealers are the things that actually are in charge of revealing a PID
9
+ # in a terminal emulator. They consume the other services to do the real
10
+ # work.
11
+ module Revealers
12
+ # extend to implement more revealers
13
+ class BaseRevealer < Service
14
+ def call(tree)
15
+ target, *rest = tree
16
+ if supports_tree?(target, rest)
17
+ return reveal_tree(tree)
18
+ end
19
+ end
20
+
21
+ # TODO
22
+ def reveal_tree(tree)
23
+ raise "not implemented"
24
+ end
25
+
26
+ # appear the first process in this process tree.
27
+ # should return nil if no action was performed.
28
+ # otherwise, return true.
29
+ def supports_tree?(target, rest)
30
+ raise "not implemented"
31
+ end
32
+
33
+ def self.register!
34
+ Appear::REVEALERS.push(self)
35
+ end
36
+ end
37
+
38
+ class MacRevealer < BaseRevealer
39
+ delegate :join_via_tty, :lsof
40
+ require_service :mac_os
41
+
42
+ def panes
43
+ raise "not implemented"
44
+ end
45
+
46
+ def reveal_hit(hit)
47
+ raise "not implemented"
48
+ end
49
+
50
+ def reveal_tree(tree)
51
+ hits = join_via_tty(tree, panes)
52
+ actual_hits = hits.uniq {|hit| hit.tty }.
53
+ reject {|hit| services.mac_os.has_gui?(hit.process) }.
54
+ each { |hit| reveal_hit(hit) }
55
+
56
+ return actual_hits.length > 0
57
+ end
58
+
59
+ # TODO: read the bundle identifier somehow, but this is close enough.
60
+ # or get the bundle identifier and enhance the process lists with it?
61
+ def has_gui_app_named?(tree, name)
62
+ tree.any? do |process|
63
+ process.name == name && services.mac_os.has_gui?(process)
64
+ end
65
+ end
66
+ end
67
+
68
+ class Iterm2 < MacRevealer
69
+ require_service :processes
70
+
71
+ def supports_tree?(target, rest)
72
+ has_gui_app_named?(rest, 'iTerm2')
73
+ end
74
+
75
+ def panes
76
+ binding.pry
77
+ pids = services.processes.pgrep('iTerm2')
78
+ services.mac_os.call_method('iterm2_panes').map do |hash|
79
+ hash[:pids] = pids
80
+ OpenStruct.new(hash)
81
+ end
82
+ end
83
+
84
+ def reveal_hit(hit)
85
+ services.mac_os.call_method('iterm2_reveal_tty', hit.tty)
86
+ end
87
+ end
88
+
89
+ class TerminalApp < MacRevealer
90
+ require_service :processes
91
+
92
+ def supports_tree?(target, rest)
93
+ has_gui_app_named?(rest, 'Terminal')
94
+ end
95
+
96
+ def panes
97
+ pids = services.processes.pgrep('Terminal.app')
98
+ services.mac_os.call_method('terminal_panes').map do |hash|
99
+ hash[:pids] = pids
100
+ OpenStruct.new(hash)
101
+ end
102
+ end
103
+
104
+ def reveal_hit(hit)
105
+ # iterm2 runs a non-gui server process. Because of implementation
106
+ # details of MacOs#has_gui?, we don't *techinically* have to worry
107
+ # about this, but we should in case I ever implement real mac
108
+ # gui-or-not lookup.
109
+ return if hit.process.name == 'iTerm2'
110
+ services.mac_os.call_method('terminal_reveal_tty', hit.tty)
111
+ end
112
+ end
113
+
114
+ class Tmux < BaseRevealer
115
+ # TODO: cache services.tmux.panes, services.tmux.clients for this revealer?
116
+ require_service :tmux
117
+ require_service :lsof
118
+ require_service :revealer
119
+ require_service :processes
120
+
121
+ def supports_tree?(target, rest)
122
+ rest.any? { |p| p.name == 'tmux' }
123
+ end
124
+
125
+ def reveal_tree(tree)
126
+ relevent_panes = Join.join(:pid, tree, services.tmux.panes)
127
+ relevent_panes.each do |pane|
128
+ log("#{self.class.name}: revealing pane #{pane}")
129
+ services.tmux.reveal_pane(pane)
130
+ end
131
+
132
+ # we should also appear the tmux client for this tree in the gui
133
+ pid = tmux_client_for_tree(tree)
134
+ if pid
135
+ services.revealer.call(pid)
136
+ end
137
+
138
+ return relevent_panes.length > 0
139
+ end
140
+
141
+ # tmux does not tell us the PIDs of any of these clients. The only way
142
+ # to find the PID of a tmux client is to lsof() the TTY that the client
143
+ # is connected to, and then deduce the client PID, which will be a tmux
144
+ # process PID that is not the server PID.
145
+ def tmux_client_for_tree(tree)
146
+ tmux_server = tree.find {|p| p.name == 'tmux'}
147
+
148
+ # join processes on tmux panes by PID.
149
+ proc_and_panes = Join.join(:pid, services.tmux.panes, tree)
150
+
151
+ # Join the list of tmux clients with process_and_pid on :session.
152
+ # In tmux, every pane is addressed by session_name:window_index:pane_index.
153
+ # This gives us back a list of all the clients that have a pane that
154
+ # contains a process in our given process tree.
155
+ proc_and_clients = Join.join(:session, services.tmux.clients, proc_and_panes)
156
+
157
+ # there *should* be only one of these, unless there are two clients
158
+ # connected to the same tmux session. In that case we just choose one
159
+ # of the clients.
160
+ client = proc_and_clients.last
161
+
162
+ # at this point it's possible that none of our tree's processes are
163
+ # alive inside tmux.
164
+ return nil unless client
165
+
166
+ tty_of_client = client[:tty]
167
+ connections_to_tty = services.lsof.lsofs(
168
+ [tty_of_client],
169
+ :pids => services.processes.pgrep('tmux')
170
+ )[tty_of_client]
171
+ client_connection = connections_to_tty.find do |conn|
172
+ (conn.command_name =~ /^tmux/) && (conn.pid != tmux_server.pid)
173
+ end
174
+
175
+ client_connection.pid if client_connection
176
+ end
177
+ end
178
+
179
+ Iterm2.register!
180
+ TerminalApp.register!
181
+ Tmux.register!
182
+ end
183
+ end
@@ -0,0 +1,101 @@
1
+ require 'open3'
2
+ require 'appear/constants'
3
+ require 'appear/service'
4
+ require 'shellwords'
5
+ require 'json'
6
+
7
+ module Appear
8
+ # raised when a command we want to run fails
9
+ class ExecutionFailure < Error
10
+ attr_reader :command, :output
11
+ def initialize(command, output)
12
+ @command = command
13
+ @output = output
14
+ super("Command #{command.inspect} failed with output #{output.inspect}")
15
+ end
16
+ end
17
+
18
+ # Service for executing commands. Better than a mixin everywhere.
19
+ class Runner < Service
20
+ # Run a command. Throws an exception if the command fails. Command can
21
+ # either be a string, or an array of command name and parameters.
22
+ # Returns the combinded STDERR and STDOUT of the command.
23
+ #
24
+ # @return String
25
+ def run(command)
26
+ start = Time.new
27
+ if command.is_a? Array
28
+ output, status = Open3.capture2e(*command)
29
+ else
30
+ output, status = Open3.capture2e(command)
31
+ end
32
+ finish = Time.new
33
+ log("Runner: ran #{command.inspect} in #{finish - start}s")
34
+ raise ExecutionFailure.new(command, output) unless status.success?
35
+ output
36
+ end
37
+ end
38
+
39
+ # Records every command run to a directory; intended to be useful for later integration tests.
40
+ class RunnerRecorder < Runner
41
+ OUTPUT_DIR = MODULE_DIR.join('spec/command_output')
42
+ INIT_AT = Time.new
43
+
44
+ def initialize(*args)
45
+ super(*args)
46
+ @command_runs = Hash.new { |h, k| h[k] = [] }
47
+ end
48
+
49
+ def run(command)
50
+ begin
51
+ result = super(command)
52
+ record_success(command, result)
53
+ return result
54
+ rescue ExecutionFailure => err
55
+ record_error(command, err)
56
+ raise err
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def command_name(command)
63
+ if command.is_a?(Array)
64
+ File.basename(command.first)
65
+ else
66
+ File.basename(command.split(/\s+/).first)
67
+ end
68
+ end
69
+
70
+ def record_success(command, result)
71
+ data = {
72
+ :command => command,
73
+ :output => result,
74
+ :status => :success,
75
+ }
76
+ record(command, data)
77
+ end
78
+
79
+ def record_error(command, err)
80
+ data = {
81
+ :command => command,
82
+ :output => err.output,
83
+ :status => :error,
84
+ }
85
+ record(command, data)
86
+ end
87
+
88
+ def record(command, data)
89
+ name = command_name(command)
90
+ run_index = @command_runs[name].length
91
+
92
+ data[:run_index] = run_index
93
+ data[:record_at] = Time.new
94
+ data[:init_at] = INIT_AT
95
+
96
+ @command_runs[name] << data
97
+ filename = "#{INIT_AT.to_i}-#{name}-run#{run_index}.json"
98
+ OUTPUT_DIR.join(filename).write(JSON.pretty_generate(data))
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,65 @@
1
+ require 'ostruct'
2
+
3
+ module Appear
4
+ # Dependency-injectable service class. Service will raise errors during
5
+ # initialization if its dependencies are not met.
6
+ class BaseService
7
+ def initialize(given_services = {})
8
+ req_service_instances = {}
9
+ self.class.required_services.each do |service|
10
+ unless given_services[service]
11
+ raise ArgumentError.new("required service #{service.inspect} not provided to instance of #{self.class.inspect}")
12
+ end
13
+
14
+ req_service_instances[service] = given_services[service]
15
+ end
16
+ @services = OpenStruct.new(req_service_instances)
17
+ end
18
+
19
+ # Delegate a method to another service. Declares a dependency on that
20
+ # service.
21
+ def self.delegate(method, service)
22
+ require_service(service)
23
+ self.send(:define_method, method) do |*args, &block|
24
+ unless @services.send(service).respond_to?(method)
25
+ raise NoMethodError.new("Would call private method #{method.inspect} on #{service.inspect}")
26
+ end
27
+ @services.send(service).send(method, *args, &block)
28
+ end
29
+ end
30
+
31
+ # List all the services required by this service class.
32
+ def self.required_services
33
+ @required_services ||= []
34
+
35
+ if self.superclass.respond_to?(:required_services)
36
+ @required_services + self.superclass.required_services
37
+ else
38
+ @required_services
39
+ end
40
+ end
41
+
42
+ # Declare a dependency on another service.
43
+ def self.require_service(name)
44
+ @required_services ||= []
45
+
46
+ return if required_services.include?(name)
47
+ @required_services << name
48
+ end
49
+
50
+
51
+ private
52
+
53
+ def services
54
+ @services
55
+ end
56
+ end
57
+
58
+ # All regular services want to log and output stuff, so they inherit from
59
+ # here.
60
+ class Service < BaseService
61
+ delegate :log, :output
62
+ delegate :log_error, :output
63
+ delegate :output, :output
64
+ end
65
+ end
@@ -0,0 +1,77 @@
1
+ require 'ostruct'
2
+ require 'appear/service'
3
+
4
+ module Appear
5
+ # The Tmux service is in charge of interacting with `tmux` processes. It is
6
+ # used by the Tmux revealer, but could also be used as the building block for
7
+ # other tmux-related scripts.
8
+ #
9
+ # see the man page for tmux if you are curious about what clients, windows,
10
+ # panes, and sessions are in Tmux world.
11
+ class Tmux < Service
12
+ delegate :run, :runner
13
+
14
+ def clients
15
+ ipc([
16
+ 'list-clients',
17
+ '-F',
18
+ format_string(
19
+ :tty => :client_tty,
20
+ :term => :client_termname,
21
+ :session => :client_session
22
+ ),
23
+ ])
24
+ end
25
+
26
+ def panes
27
+ panes = ipc([
28
+ 'list-panes',
29
+ '-a',
30
+ '-F',
31
+ format_string(
32
+ :pid => :pane_pid,
33
+ :session => :session_name,
34
+ :window => :window_index,
35
+ :pane => :pane_index,
36
+ :command_name => :pane_current_command,
37
+ :active => :pane_active)
38
+ ])
39
+
40
+ panes.each do |pane|
41
+ pane.window = pane.window.to_i
42
+ pane.pid = pane.pid.to_i
43
+ pane.active = pane.active.to_i != 0
44
+ end
45
+
46
+ panes
47
+ end
48
+
49
+ def reveal_pane(pane)
50
+ ipc(['select-pane', '-t', "#{pane.session}:#{pane.window}.#{pane.pane}"])
51
+ ipc(['select-window', '-t', "#{pane.session}:#{pane.window}"])
52
+ end
53
+
54
+ private
55
+
56
+ def ipc(args)
57
+ res = run(['tmux'] + args)
58
+ res.lines.map do |line|
59
+ info = {}
60
+ line.strip.split(' ').each do |pair|
61
+ key, *value = pair.split(':')
62
+ info[key.to_sym] = value.join(':')
63
+ end
64
+ OpenStruct.new(info)
65
+ end
66
+ end
67
+
68
+ def format_string(spec)
69
+ result = ""
70
+ spec.each do |key, value|
71
+ part = ' ' + key.to_s + ':#{' + value.to_s + '}'
72
+ result += part
73
+ end
74
+ result
75
+ end
76
+ end
77
+ end
data/screenshot.gif ADDED
Binary file
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env osascript -l JavaScript
2
+ // osascript is a OS X-native scripting tool that allows scripting the system.
3
+ // Usually scripts are written in AppleScript, but AppleScript really sucks, so
4
+ // we're going to write Javascript instead.
5
+ //
6
+ // osascript is interesting because it can inspect the state of the OS X gui,
7
+ // including enumerating windows and inspecting window contents, if
8
+ // accessibility is enabled in System Preferences.
9
+ //
10
+ // documentation:
11
+ // https://developer.apple.com/library/mac/releasenotes/InterapplicationCommunication/RN-JavaScriptForAutomation/Articles/OSX10-10.html#//apple_ref/doc/uid/TP40014508-CH109-SW1
12
+
13
+ // This script is called from Appear to interact with macOS GUI apps. Both
14
+ // Terminal.app and iTerm2 publish Applescript APIs, so they're super-easy to
15
+ // script without accessibility!
16
+
17
+ // libaries -----------------------------------------------
18
+ var SystemEvents = Application('System Events')
19
+
20
+ // allows using things like ThisApp.displayDialog
21
+ var ThisApp = Application.currentApplication()
22
+ ThisApp.includeStandardAdditions = true
23
+
24
+ // helpful
25
+ var ScriptContext = this
26
+
27
+ // -----------------------------------------------------------
28
+ var PROGRAM_NAME = 'appear-macOS-helper'
29
+ var Methods = {}
30
+
31
+ // entrypoint -------------------------------------------------
32
+ // this is the main method of this script when it is called from the command line.
33
+ // the remainer of the file is parsed and evaluated, and then the osascript
34
+ // environment calls this function with two arguments:
35
+ // 1: Array<String> argv
36
+ // 2: Object ???. Could be ScriptContext?
37
+ function run(argv, unknown) {
38
+ var method_name = argv[0]
39
+ var data = argv[1]
40
+ var message = "running method " + method_name
41
+
42
+ if (data) {
43
+ data = JSON.parse(data)
44
+ message = message + " with data"
45
+ }
46
+
47
+ try {
48
+ var method = Methods[method_name]
49
+ if (!method) throw new Error('unknown method ' + method_name)
50
+ // helpful for debugging sometimes! don't delete. just un-comment
51
+ //say(message)
52
+ var result = ok(method(data))
53
+ Subprocess.cleanup()
54
+ return JSON.stringify(result)
55
+ } catch (err) {
56
+ //say("failed because " + err.message)
57
+ Subprocess.cleanup()
58
+ return JSON.stringify(error(err))
59
+ }
60
+ }
61
+
62
+ function ok(result) {
63
+ return {status: 'ok', value: result}
64
+ }
65
+
66
+ function error(err) {
67
+ return {status: 'error', error: { message: err.message, stack: err.stack }}
68
+ }
69
+
70
+ // ------------------------------------------------------------
71
+
72
+ function TerminalEmulator() {}
73
+ TerminalEmulator.prototype.forEachPane = function(callback) {}
74
+ TerminalEmulator.prototype.panes = function panes() {
75
+ var panes = []
76
+ this.forEachPane(function(pane) {
77
+ panes.push(pane)
78
+ })
79
+ return panes;
80
+ }
81
+ TerminalEmulator.prototype.revealTty = function(tty) {}
82
+
83
+ // ------------------------------------------------------------
84
+ // Iterm2 library
85
+
86
+ function Iterm2() {
87
+ this.app = Application('com.googlecode.iterm2')
88
+ }
89
+
90
+ Iterm2.prototype = new TerminalEmulator();
91
+
92
+ Iterm2.prototype.forEachPane = function forEachPane(callback) {
93
+ this.app.windows().forEach(function(win) {
94
+ win.tabs().forEach(function(tab) {
95
+ tab.sessions().forEach(function(session) {
96
+ callback({
97
+ window: win,
98
+ tab: tab,
99
+ session: session,
100
+ tty: session.tty(),
101
+ })
102
+ })
103
+ })
104
+ })
105
+ }
106
+
107
+ Iterm2.prototype.revealTty = function revealTty(tty) {
108
+ var success = false;
109
+
110
+ this.forEachPane(function(pane) {
111
+ if (pane.tty !== tty) return
112
+ if (success) return
113
+
114
+ pane.tab.select()
115
+ pane.session.select()
116
+ pane.window.select()
117
+ success = true;
118
+ })
119
+
120
+ if (success) smartActivate(this.app)
121
+ return success;
122
+ }
123
+
124
+ Methods['iterm2_reveal_tty'] = function iterm2_reveal_tty(tty) {
125
+ var iterm2 = new Iterm2()
126
+ return iterm2.revealTty(tty)
127
+ }
128
+
129
+ Methods['iterm2_panes'] = function iterm2_panes() {
130
+ var iterm2 = new Iterm2()
131
+ return iterm2.panes()
132
+ }
133
+
134
+ // -------------------------------------------------------------
135
+ // Terminal.app library
136
+
137
+ function Terminal() {
138
+ this.app = Application('com.apple.Terminal')
139
+ }
140
+
141
+ Terminal.prototype = new TerminalEmulator();
142
+
143
+ Terminal.prototype.forEachPane = function iteratePanes(callback) {
144
+ this.app.windows().forEach(function(win) {
145
+ win.tabs().forEach(function(tab) {
146
+ callback({
147
+ window: win,
148
+ tab: tab,
149
+ tty: tab.tty(),
150
+ })
151
+ })
152
+ })
153
+ }
154
+
155
+ Terminal.prototype.revealTty = function revealTty(tty) {
156
+ var success = false;
157
+
158
+ this.forEachPane(function(pane) {
159
+ if (pane.tty !== tty) return;
160
+ if (success) return;
161
+
162
+ pane.tab.selected = true
163
+ pane.window.index = 0
164
+ success = true
165
+ })
166
+
167
+ if (success) smartActivate(this.app)
168
+ return success
169
+ }
170
+
171
+ Methods['terminal_reveal_tty'] = function terminal_reveal_tty(tty) {
172
+ var terminal = new Terminal()
173
+ return terminal.revealTty(tty)
174
+ }
175
+
176
+ Methods['terminal_panes'] = function terminal_panes() {
177
+ var terminal = new Terminal()
178
+ return terminal.panes()
179
+ }
180
+
181
+ // for tests ----------------------------------------------
182
+
183
+ Methods['test_ok'] = function test_ok(arg1) {
184
+ return arg1
185
+ }
186
+
187
+ Methods['test_err'] = function test_err(arg1) {
188
+ var error = new Error('testing error handling')
189
+ error.arg1 = arg1
190
+ throw error
191
+ }
192
+
193
+
194
+ // paths ---------------------------------------------------
195
+ Paths = (function(){
196
+ function splitPath(path) {
197
+ return path.split('/')
198
+ }
199
+
200
+ function joinPath(pathArray) {
201
+ var res = pathArray.join('/')
202
+ if (res[0] != '/') res = '/' + res
203
+ return res
204
+ }
205
+
206
+ function local(pathIn) {
207
+ var file = ThisApp.pathTo(ScriptContext).toString()
208
+ return join(dirname(file), pathIn)
209
+ }
210
+
211
+ function dirname(path) {
212
+ return joinPath(splitPath(path).slice(0, -1))
213
+ }
214
+
215
+ function basename(path) {
216
+ return splitPath(path).slice(-1)[0]
217
+ }
218
+
219
+ function join(root, extend) {
220
+ return joinPath(splitPath(root).concat(splitPath(extend)).filter(Boolean))
221
+ }
222
+
223
+ return {
224
+ local: local,
225
+ dirname: dirname,
226
+ basename: basename,
227
+ join: join,
228
+ }
229
+ })();
230
+
231
+
232
+ // Subprocess ---------------------------------------------------
233
+
234
+ var Subprocess = (function() {
235
+ var FILENAME_PREFIX = Paths.join('/tmp', PROGRAM_NAME + '-' + Date.now() + Math.random() + '-')
236
+ var _tmpfile = 0;
237
+ var _threads = [];
238
+
239
+ // if the script doesn't have a file to write to, it will block still.
240
+ function tmpfile() {
241
+ var filename = FILENAME_PREFIX + _tmpfile++ + '.log'
242
+ return filename
243
+ }
244
+
245
+ // fork a command, and return the PID.
246
+ function fork(command, detatch) {
247
+ var output = tmpfile()
248
+ var script = command + ' &> ' + output + ' & echo $!'
249
+ var thread = {
250
+ command: command,
251
+ pid: ThisApp.doShellScript(script),
252
+ output: output,
253
+ detatch: detatch,
254
+ }
255
+ console.log('forked process', JSON.stringify(thread, null, 2))
256
+ _threads.push(thread)
257
+ return thread
258
+ }
259
+
260
+ function kill(pid) {
261
+ try {
262
+ // this will raise an error if the kill command cant find that process.
263
+ ThisApp.doShellScript('kill ' + pid)
264
+ return true
265
+ } catch (err) {
266
+ return false
267
+ }
268
+ }
269
+
270
+ function del(filename) {
271
+ var path = Path(filename)
272
+ if (SystemEvents.exists(path)) {
273
+ SystemEvents.delete(path)
274
+ return true
275
+ }
276
+ return false
277
+ }
278
+
279
+ function cleanup() {
280
+ _threads.forEach(function(thread) {
281
+ if (!thread.detatch) kill(thread.pid)
282
+ del(thread.output)
283
+ })
284
+ }
285
+
286
+ return {
287
+ fork: fork,
288
+ cleanup: cleanup,
289
+ }
290
+ })();
291
+
292
+ // various utils ----------------------------------------------
293
+
294
+ function smartActivate(app) {
295
+ if (!app.frontmost()) {
296
+ app.activate()
297
+ }
298
+ }
299
+
300
+ function quotedForm(s) {
301
+ return "'" + s.replace(/'/g, "'\\''") + "'"
302
+ }
303
+
304
+ // non-blocking say text
305
+ function say(text) {
306
+ Subprocess.fork('say ' + quotedForm(text), true)
307
+ }
308
+
309
+ // debugging -----------------------------------------------
310
+ // these are left in here because they're useful if you ever want to develop this file again.
311
+
312
+ function log(obj, fieldName) {
313
+ var fn = fieldName || '>'
314
+ console.log(fn, Automation.getDisplayString(obj))
315
+ }
316
+
317
+ function typeName(obj) {
318
+ return Object.prototype.toString.call(obj)
319
+ }
320
+
321
+ function inspect(obj) {
322
+ console.log("--------v")
323
+ log(obj)
324
+ if (obj !== undefined) inspectDetail(obj)
325
+ console.log('--------^')
326
+ }
327
+
328
+ function inspectDetail(obj) {
329
+ var proto = obj.__proto__;
330
+ var constructor = obj.constructor;
331
+ var name = typeName(obj)
332
+
333
+ console.log('')
334
+
335
+ log(name, 'type name')
336
+ log(proto, 'prototype')
337
+
338
+ console.log('')
339
+
340
+ log(Object.keys(obj), 'keys')
341
+ for (var thing in obj) {
342
+ log(obj[thing], 'prop ' + Automation.getDisplayString(thing) + ':')
343
+ }
344
+
345
+ console.log('')
346
+
347
+ log(constructor, 'constructor')
348
+ }
349
+ // ---------------------------------------------------------