appear 1.0.0

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.
@@ -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
+ // ---------------------------------------------------------