rubylens 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +67 -39
- data/assets/runtime/report.js +1607 -251
- data/assets/shells/report.html +9 -7
- data/assets/shells/showcase.html +1 -0
- data/assets/styles/report.css +51 -25
- data/assets/styles/showcase.css +3 -0
- data/lib/rubylens/art_model_builder.rb +62 -10
- data/lib/rubylens/artifact_marker.rb +17 -0
- data/lib/rubylens/atomic_output.rb +27 -0
- data/lib/rubylens/cli.rb +53 -36
- data/lib/rubylens/clip/chrome_page.rb +183 -0
- data/lib/rubylens/clip/deadline_io.rb +28 -0
- data/lib/rubylens/clip/renderer.rb +113 -0
- data/lib/rubylens/clip/toolchain.rb +82 -0
- data/lib/rubylens/clip/web_socket_channel.rb +116 -0
- data/lib/rubylens/clip_generator.rb +102 -0
- data/lib/rubylens/default_output.rb +24 -0
- data/lib/rubylens/generator.rb +9 -10
- data/lib/rubylens/git_repository.rb +13 -7
- data/lib/rubylens/index/manifest.rb +8 -7
- data/lib/rubylens/index/rspec_extractor.rb +12 -10
- data/lib/rubylens/index/rubydex_adapter.rb +290 -140
- data/lib/rubylens/index/source_path.rb +12 -11
- data/lib/rubylens/model/dependency_aggregation.rb +9 -7
- data/lib/rubylens/morphology_classifier.rb +52 -12
- data/lib/rubylens/report_writer.rb +5 -20
- data/lib/rubylens/showcase_generator.rb +5 -9
- data/lib/rubylens/showcase_model.rb +12 -14
- data/lib/rubylens/showcase_writer.rb +0 -6
- data/lib/rubylens/version.rb +1 -1
- data/lib/rubylens.rb +7 -0
- metadata +10 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "../errors"
|
|
10
|
+
require_relative "deadline_io"
|
|
11
|
+
require_relative "web_socket_channel"
|
|
12
|
+
|
|
13
|
+
module RubyLens
|
|
14
|
+
module Clip
|
|
15
|
+
# Runs a headless Chrome/Chromium with an ephemeral DevTools port and a
|
|
16
|
+
# throwaway profile, attaches to the page that loads the showcase HTML,
|
|
17
|
+
# and exposes the two operations clip rendering needs: evaluate and
|
|
18
|
+
# screenshot. The port comes from the profile's DevToolsActivePort file,
|
|
19
|
+
# so parallel clips never race for a fixed port.
|
|
20
|
+
class ChromePage
|
|
21
|
+
HEADLESS_FLAGS = %w[
|
|
22
|
+
--headless=new
|
|
23
|
+
--remote-debugging-port=0
|
|
24
|
+
--no-first-run
|
|
25
|
+
--no-default-browser-check
|
|
26
|
+
--disable-background-networking
|
|
27
|
+
--disable-component-update
|
|
28
|
+
--disable-sync
|
|
29
|
+
--mute-audio
|
|
30
|
+
--hide-scrollbars
|
|
31
|
+
--force-device-scale-factor=1
|
|
32
|
+
--force-color-profile=srgb
|
|
33
|
+
--enable-unsafe-swiftshader
|
|
34
|
+
].freeze
|
|
35
|
+
LAUNCH_TIMEOUT_SECONDS = 30
|
|
36
|
+
|
|
37
|
+
def self.open(executable:, url:, width:, height:)
|
|
38
|
+
page = new(executable:, url:, width:, height:)
|
|
39
|
+
begin
|
|
40
|
+
yield page
|
|
41
|
+
ensure
|
|
42
|
+
page.close
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(executable:, url:, width:, height:)
|
|
47
|
+
@profile = Dir.mktmpdir("rubylens-clip-")
|
|
48
|
+
@next_id = 0
|
|
49
|
+
@responses = {}
|
|
50
|
+
flags = HEADLESS_FLAGS.dup
|
|
51
|
+
# Chrome refuses its sandbox as root (typical for containers and CI).
|
|
52
|
+
flags << "--no-sandbox" if Process.euid.zero?
|
|
53
|
+
flags << "--window-size=#{width},#{height}"
|
|
54
|
+
flags << "--user-data-dir=#{@profile}"
|
|
55
|
+
@pid = Process.spawn(executable, *flags, url, %i[out err] => File::NULL)
|
|
56
|
+
port = wait_for_devtools_port
|
|
57
|
+
@channel = WebSocketChannel.new("127.0.0.1", port, page_target_path(port))
|
|
58
|
+
# The headless window includes non-viewport space; pin the viewport so
|
|
59
|
+
# captures are exactly the stage size.
|
|
60
|
+
command("Emulation.setDeviceMetricsOverride", {
|
|
61
|
+
"width" => width, "height" => height, "deviceScaleFactor" => 1, "mobile" => false
|
|
62
|
+
}, timeout: 30)
|
|
63
|
+
rescue Errno::ENOENT => error
|
|
64
|
+
abort_start
|
|
65
|
+
raise Error, "could not launch Chrome at #{executable}: #{error.message}"
|
|
66
|
+
rescue SystemCallError => error
|
|
67
|
+
# A startup race (Chrome exiting or closing its port mid-attach) must
|
|
68
|
+
# surface as a normal rubylens error, not a raw Errno stack trace.
|
|
69
|
+
abort_start
|
|
70
|
+
raise Error, "could not attach to Chrome's DevTools endpoint: #{error.message}"
|
|
71
|
+
rescue StandardError
|
|
72
|
+
abort_start
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def evaluate(expression, await: false, timeout: 30)
|
|
77
|
+
result = command("Runtime.evaluate", {
|
|
78
|
+
"expression" => expression,
|
|
79
|
+
"returnByValue" => true,
|
|
80
|
+
"awaitPromise" => await,
|
|
81
|
+
}, timeout:)
|
|
82
|
+
if (details = result["exceptionDetails"])
|
|
83
|
+
raise Error, "the showcase page raised: #{details.dig("exception", "description") || details["text"]}"
|
|
84
|
+
end
|
|
85
|
+
result.dig("result", "value")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def screenshot_png(timeout: 60)
|
|
89
|
+
data = command("Page.captureScreenshot", { "format" => "png", "optimizeForSpeed" => true }, timeout:)
|
|
90
|
+
Base64.decode64(data.fetch("data"))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def close
|
|
94
|
+
begin
|
|
95
|
+
command("Browser.close", {}, timeout: 3) if @channel
|
|
96
|
+
rescue Error
|
|
97
|
+
terminate
|
|
98
|
+
end
|
|
99
|
+
Process.wait(@pid) if @pid
|
|
100
|
+
rescue Errno::ECHILD
|
|
101
|
+
nil
|
|
102
|
+
ensure
|
|
103
|
+
@channel&.close
|
|
104
|
+
FileUtils.remove_entry(@profile) if @profile && File.directory?(@profile)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# A failed start never reaches ChromePage.open's ensure, so the spawned
|
|
110
|
+
# browser and throwaway profile must be cleaned up before re-raising.
|
|
111
|
+
# The unconditional kill keeps close's Process.wait from hanging on a
|
|
112
|
+
# live Chrome with no channel.
|
|
113
|
+
def abort_start
|
|
114
|
+
terminate if @pid
|
|
115
|
+
close
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def terminate
|
|
119
|
+
Process.kill("KILL", @pid)
|
|
120
|
+
rescue Errno::ESRCH
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def command(method, params, timeout:)
|
|
125
|
+
id = (@next_id += 1)
|
|
126
|
+
@channel.send_text(JSON.generate({ "id" => id, "method" => method, "params" => params }))
|
|
127
|
+
loop do
|
|
128
|
+
if (message = @responses.delete(id))
|
|
129
|
+
if (error = message["error"])
|
|
130
|
+
raise Error, "Chrome rejected #{method}: #{error["message"]}"
|
|
131
|
+
end
|
|
132
|
+
return message.fetch("result")
|
|
133
|
+
end
|
|
134
|
+
message = JSON.parse(@channel.read_text(timeout:))
|
|
135
|
+
@responses[message["id"]] = message if message["id"]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def wait_for_devtools_port
|
|
140
|
+
port_path = File.join(@profile, "DevToolsActivePort")
|
|
141
|
+
deadline = DeadlineIO.deadline(LAUNCH_TIMEOUT_SECONDS)
|
|
142
|
+
while DeadlineIO.monotonic < deadline
|
|
143
|
+
if File.exist?(port_path)
|
|
144
|
+
# to_i reads the leading port line and tolerates a mid-write file.
|
|
145
|
+
port = File.read(port_path).to_i
|
|
146
|
+
return port if port.positive?
|
|
147
|
+
end
|
|
148
|
+
if Process.waitpid(@pid, Process::WNOHANG)
|
|
149
|
+
@pid = nil
|
|
150
|
+
raise Error, "Chrome exited while starting up; it may not support headless rendering here"
|
|
151
|
+
end
|
|
152
|
+
sleep 0.05
|
|
153
|
+
end
|
|
154
|
+
raise Error, "Chrome did not publish a DevTools port within #{LAUNCH_TIMEOUT_SECONDS} seconds"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def page_target_path(port)
|
|
158
|
+
deadline = DeadlineIO.deadline(LAUNCH_TIMEOUT_SECONDS)
|
|
159
|
+
while DeadlineIO.monotonic < deadline
|
|
160
|
+
begin
|
|
161
|
+
target = JSON.parse(http_get(port, "/json/list")).find { |info| info["type"] == "page" }
|
|
162
|
+
if (socket_url = target&.fetch("webSocketDebuggerUrl", nil))
|
|
163
|
+
return URI(socket_url).path
|
|
164
|
+
end
|
|
165
|
+
rescue Error, SystemCallError, JSON::ParserError, Timeout::Error, EOFError
|
|
166
|
+
# The HTTP endpoint can lag DevToolsActivePort; retry until the deadline.
|
|
167
|
+
end
|
|
168
|
+
sleep 0.05
|
|
169
|
+
end
|
|
170
|
+
raise Error, "Chrome never exposed the showcase page for capture"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The explicit nil proxy argument keeps this loopback request off any
|
|
174
|
+
# configured HTTP proxy.
|
|
175
|
+
def http_get(port, path, timeout: 10)
|
|
176
|
+
http = Net::HTTP.new("127.0.0.1", port, nil)
|
|
177
|
+
http.open_timeout = timeout
|
|
178
|
+
http.read_timeout = timeout
|
|
179
|
+
http.get(path).body
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module RubyLens
|
|
6
|
+
module Clip
|
|
7
|
+
# Deadline-bounded, non-blocking socket reads shared by the DevTools
|
|
8
|
+
# WebSocket channel and the local DevTools HTTP client.
|
|
9
|
+
module DeadlineIO
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
13
|
+
|
|
14
|
+
def deadline(timeout) = monotonic + timeout
|
|
15
|
+
|
|
16
|
+
def read_chunk(socket, deadline, closed_message:, timeout_message:)
|
|
17
|
+
remaining = deadline - monotonic
|
|
18
|
+
raise Error, timeout_message if remaining <= 0
|
|
19
|
+
IO.select([socket], nil, nil, remaining) or raise Error, timeout_message
|
|
20
|
+
chunk = socket.read_nonblock(1 << 20, exception: false)
|
|
21
|
+
raise Error, closed_message if chunk.nil?
|
|
22
|
+
chunk.is_a?(String) ? chunk : "".b
|
|
23
|
+
rescue SystemCallError, IOError => error
|
|
24
|
+
raise Error, "#{closed_message}: #{error.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "chrome_page"
|
|
7
|
+
require_relative "deadline_io"
|
|
8
|
+
|
|
9
|
+
module RubyLens
|
|
10
|
+
module Clip
|
|
11
|
+
# Renders a showcase HTML file into an H.264 MP4: headless Chrome steps
|
|
12
|
+
# the showcase's deterministic clip frames, and each captured frame is
|
|
13
|
+
# streamed straight into ffmpeg so no frame images ever touch disk. One
|
|
14
|
+
# render covers exactly one camera loop, so the video loops seamlessly.
|
|
15
|
+
class Renderer
|
|
16
|
+
STAGE_WIDTH = 1920
|
|
17
|
+
STAGE_HEIGHT = 1080
|
|
18
|
+
DEFAULT_FPS = 30
|
|
19
|
+
READY_TIMEOUT_SECONDS = 120
|
|
20
|
+
MARKER_COMMENT = "RubyLens clip"
|
|
21
|
+
|
|
22
|
+
def initialize(chrome:, ffmpeg:, fps: DEFAULT_FPS, frame_limit: nil, progress: nil)
|
|
23
|
+
@chrome = chrome
|
|
24
|
+
@ffmpeg = ffmpeg
|
|
25
|
+
@fps = Integer(fps)
|
|
26
|
+
@frame_limit = frame_limit
|
|
27
|
+
@progress = progress
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def render(showcase_html:, output:)
|
|
31
|
+
ChromePage.open(executable: @chrome, url: file_url(showcase_html), width: STAGE_WIDTH, height: STAGE_HEIGHT) do |page|
|
|
32
|
+
wait_until_ready(page)
|
|
33
|
+
preset = begin_clip(page)
|
|
34
|
+
frames = frame_count(preset)
|
|
35
|
+
encode(page, frames, output)
|
|
36
|
+
end
|
|
37
|
+
output
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def file_url(path)
|
|
43
|
+
"file://#{URI::DEFAULT_PARSER.escape(File.expand_path(path))}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def wait_until_ready(page)
|
|
47
|
+
deadline = DeadlineIO.deadline(READY_TIMEOUT_SECONDS)
|
|
48
|
+
until page.evaluate("document.documentElement?.dataset.showcaseReady === 'true'")
|
|
49
|
+
raise Error, "the showcase page never finished loading" if DeadlineIO.monotonic > deadline
|
|
50
|
+
|
|
51
|
+
sleep 0.1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def begin_clip(page)
|
|
56
|
+
preset = page.evaluate("beginShowcaseClip()")
|
|
57
|
+
status = preset.is_a?(Hash) ? preset["status"] : nil
|
|
58
|
+
raise Error, "Chrome could not initialize WebGL2, which clip rendering requires" if status == "renderer-unavailable"
|
|
59
|
+
raise Error, "the page is not a RubyLens showcase" unless status == "ok"
|
|
60
|
+
|
|
61
|
+
preset
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def frame_count(preset)
|
|
65
|
+
frames = Integer(preset.fetch("durationMs")) * @fps / 1000
|
|
66
|
+
@frame_limit ? [frames, Integer(@frame_limit)].min : frames
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ffmpeg_arguments(output)
|
|
70
|
+
[
|
|
71
|
+
@ffmpeg, "-loglevel", "error", "-y",
|
|
72
|
+
"-f", "image2pipe", "-c:v", "png", "-framerate", @fps.to_s, "-i", "-",
|
|
73
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
|
|
74
|
+
"-movflags", "+faststart",
|
|
75
|
+
"-metadata", "comment=#{MARKER_COMMENT}",
|
|
76
|
+
"-f", "mp4", output
|
|
77
|
+
]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def encode(page, frames, output)
|
|
81
|
+
stdin, stderr, waiter = Open3.popen3(*ffmpeg_arguments(output)).then { |i, o, e, t| o.close; [i, e, t] }
|
|
82
|
+
stdin.binmode
|
|
83
|
+
stderr_reader = Thread.new { stderr.read }
|
|
84
|
+
begin
|
|
85
|
+
frames.times do |index|
|
|
86
|
+
page.evaluate("renderShowcaseClipFrame(#{index}, #{@fps})", await: true)
|
|
87
|
+
stdin.write(page.screenshot_png)
|
|
88
|
+
@progress&.call(index + 1, frames)
|
|
89
|
+
end
|
|
90
|
+
stdin.close
|
|
91
|
+
raise Error, "ffmpeg failed while encoding the clip: #{failure_reason(stderr_reader.value)}" unless waiter.value.success?
|
|
92
|
+
rescue Errno::EPIPE
|
|
93
|
+
raise Error, "ffmpeg stopped accepting frames: #{failure_reason(stderr_reader.value)}"
|
|
94
|
+
ensure
|
|
95
|
+
# On a capture-side failure, closing stdin lets ffmpeg finish and
|
|
96
|
+
# exit, the waiter reaps it, and the stderr reader ends on EOF, so
|
|
97
|
+
# only the original error surfaces.
|
|
98
|
+
stdin.close unless stdin.closed?
|
|
99
|
+
waiter.value
|
|
100
|
+
stderr_reader.value
|
|
101
|
+
stderr.close unless stderr.closed?
|
|
102
|
+
end
|
|
103
|
+
rescue Errno::ENOENT => error
|
|
104
|
+
raise Error, "could not run ffmpeg at #{@ffmpeg}: #{error.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def failure_reason(stderr_output)
|
|
108
|
+
lines = stderr_output.to_s.strip.lines.last(3).map(&:strip)
|
|
109
|
+
lines.empty? ? "no error output" : lines.join(" / ")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module RubyLens
|
|
6
|
+
module Clip
|
|
7
|
+
# Locates the two external tools clip rendering needs: a Chrome/Chromium
|
|
8
|
+
# binary and ffmpeg. Environment overrides win, then PATH, then well-known
|
|
9
|
+
# install locations. Discovery failures raise with install guidance so the
|
|
10
|
+
# command fails fast, before any indexing work.
|
|
11
|
+
module Toolchain
|
|
12
|
+
CHROME_ENV = "RUBYLENS_CHROME"
|
|
13
|
+
FFMPEG_ENV = "RUBYLENS_FFMPEG"
|
|
14
|
+
CHROME_COMMANDS = %w[google-chrome google-chrome-stable chromium chromium-browser chrome].freeze
|
|
15
|
+
CHROME_KNOWN_PATHS = [
|
|
16
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
17
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
18
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
19
|
+
].freeze
|
|
20
|
+
PLAYWRIGHT_CHROME_GLOBS = %w[
|
|
21
|
+
chromium
|
|
22
|
+
chromium-*/chrome-linux/chrome
|
|
23
|
+
chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def chrome_path
|
|
29
|
+
from_environment(CHROME_ENV) ||
|
|
30
|
+
from_path(CHROME_COMMANDS) ||
|
|
31
|
+
from_known_paths(CHROME_KNOWN_PATHS + playwright_chrome_candidates) ||
|
|
32
|
+
raise(Error, "clip rendering needs Chrome or Chromium; install Google Chrome, " \
|
|
33
|
+
"or point #{CHROME_ENV} at a Chrome-compatible browser binary")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ffmpeg_path
|
|
37
|
+
from_environment(FFMPEG_ENV) ||
|
|
38
|
+
from_path(%w[ffmpeg]) ||
|
|
39
|
+
raise(Error, "clip rendering needs ffmpeg; install it (for example `brew install ffmpeg` " \
|
|
40
|
+
"or `apt install ffmpeg`), or point #{FFMPEG_ENV} at an ffmpeg binary")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def from_environment(name)
|
|
44
|
+
value = ENV.fetch(name, nil)
|
|
45
|
+
return nil if value.nil? || value.empty?
|
|
46
|
+
raise Error, "#{name} is set to #{value}, which is not an executable file" unless executable?(value)
|
|
47
|
+
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def from_path(commands)
|
|
52
|
+
directories = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
53
|
+
commands.each do |command|
|
|
54
|
+
directories.each do |directory|
|
|
55
|
+
next if directory.empty?
|
|
56
|
+
|
|
57
|
+
candidate = File.join(directory, command)
|
|
58
|
+
return candidate if executable?(candidate)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def from_known_paths(paths)
|
|
65
|
+
paths.find { |path| executable?(path) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def playwright_chrome_candidates
|
|
69
|
+
root = ENV.fetch("PLAYWRIGHT_BROWSERS_PATH", nil)
|
|
70
|
+
root = File.expand_path("~/.cache/ms-playwright") if root.nil? || root.empty?
|
|
71
|
+
PLAYWRIGHT_CHROME_GLOBS.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.sort
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
# File.expand_path("~") raises when HOME is unset.
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def executable?(path)
|
|
78
|
+
File.file?(path) && File.executable?(path)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest/sha1"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "socket"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "deadline_io"
|
|
9
|
+
|
|
10
|
+
module RubyLens
|
|
11
|
+
module Clip
|
|
12
|
+
# Minimal RFC 6455 client for Chrome's local DevTools WebSocket endpoint,
|
|
13
|
+
# so clip rendering needs no gem or Node dependencies. Client frames are
|
|
14
|
+
# masked text; ping, close, and fragmented server frames are handled;
|
|
15
|
+
# extensions are never negotiated.
|
|
16
|
+
class WebSocketChannel
|
|
17
|
+
HANDSHAKE_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
18
|
+
TEXT_OPCODE = 1
|
|
19
|
+
CONTINUATION_OPCODE = 0
|
|
20
|
+
CLOSE_OPCODE = 8
|
|
21
|
+
PING_OPCODE = 9
|
|
22
|
+
PONG_OPCODE = 10
|
|
23
|
+
|
|
24
|
+
def initialize(host, port, path, timeout: 15)
|
|
25
|
+
@socket = TCPSocket.new(host, port)
|
|
26
|
+
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
27
|
+
@buffer = +"".b
|
|
28
|
+
handshake(host, port, path, timeout)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def send_text(payload)
|
|
32
|
+
bytes = payload.b
|
|
33
|
+
length = bytes.bytesize
|
|
34
|
+
header = if length < 126 then [0x80 | TEXT_OPCODE, 0x80 | length].pack("CC")
|
|
35
|
+
elsif length < 65_536 then [0x80 | TEXT_OPCODE, 0x80 | 126, length].pack("CCn")
|
|
36
|
+
else [0x80 | TEXT_OPCODE, 0x80 | 127, length].pack("CCQ>")
|
|
37
|
+
end
|
|
38
|
+
mask = SecureRandom.bytes(4)
|
|
39
|
+
@socket.write(header + mask + apply_mask(bytes, mask))
|
|
40
|
+
rescue SystemCallError, IOError => error
|
|
41
|
+
raise Error, "lost the Chrome DevTools connection: #{error.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read_text(timeout: 30)
|
|
45
|
+
deadline = DeadlineIO.deadline(timeout)
|
|
46
|
+
message = +"".b
|
|
47
|
+
loop do
|
|
48
|
+
finished, opcode, payload = read_frame(deadline)
|
|
49
|
+
case opcode
|
|
50
|
+
when TEXT_OPCODE, CONTINUATION_OPCODE then message << payload
|
|
51
|
+
when CLOSE_OPCODE then raise Error, "Chrome closed the DevTools connection"
|
|
52
|
+
when PING_OPCODE then send_control(PONG_OPCODE, payload)
|
|
53
|
+
end
|
|
54
|
+
return message.force_encoding(Encoding::UTF_8) if finished && opcode <= TEXT_OPCODE && !message.empty?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close
|
|
59
|
+
@socket.close unless @socket.closed?
|
|
60
|
+
rescue IOError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def handshake(host, port, path, timeout)
|
|
67
|
+
deadline = DeadlineIO.deadline(timeout)
|
|
68
|
+
key = SecureRandom.base64(16)
|
|
69
|
+
@socket.write("GET #{path} HTTP/1.1\r\nHost: #{host}:#{port}\r\n" \
|
|
70
|
+
"Upgrade: websocket\r\nConnection: Upgrade\r\n" \
|
|
71
|
+
"Sec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
|
|
72
|
+
response = +"".b
|
|
73
|
+
response << read_chunk(deadline) until response.include?("\r\n\r\n")
|
|
74
|
+
head, _, rest = response.partition("\r\n\r\n")
|
|
75
|
+
raise Error, "Chrome refused the DevTools WebSocket: #{head.lines.first&.strip}" unless head.start_with?("HTTP/1.1 101")
|
|
76
|
+
|
|
77
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest(key + HANDSHAKE_GUID))
|
|
78
|
+
unless head.match?(/^sec-websocket-accept:\s*#{Regexp.escape(expected)}\s*$/i)
|
|
79
|
+
raise Error, "Chrome DevTools WebSocket handshake failed verification"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@buffer << rest
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply_mask(bytes, mask)
|
|
86
|
+
masked = bytes.dup
|
|
87
|
+
masked.bytesize.times { |index| masked.setbyte(index, masked.getbyte(index) ^ mask.getbyte(index % 4)) }
|
|
88
|
+
masked
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def send_control(opcode, payload)
|
|
92
|
+
mask = SecureRandom.bytes(4)
|
|
93
|
+
@socket.write([0x80 | opcode, 0x80 | payload.bytesize].pack("CC") + mask + apply_mask(payload, mask))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read_frame(deadline)
|
|
97
|
+
first, second = read_exact(2, deadline).unpack("CC")
|
|
98
|
+
length = second & 0x7F
|
|
99
|
+
length = read_exact(2, deadline).unpack1("n") if length == 126
|
|
100
|
+
length = read_exact(8, deadline).unpack1("Q>") if length == 127
|
|
101
|
+
[first.anybits?(0x80), first & 0x0F, read_exact(length, deadline)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def read_exact(length, deadline)
|
|
105
|
+
@buffer << read_chunk(deadline) while @buffer.bytesize < length
|
|
106
|
+
@buffer.slice!(0, length)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def read_chunk(deadline)
|
|
110
|
+
DeadlineIO.read_chunk(@socket, deadline,
|
|
111
|
+
closed_message: "Chrome closed the DevTools connection",
|
|
112
|
+
timeout_message: "timed out waiting for Chrome")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "artifact_marker"
|
|
4
|
+
require_relative "atomic_output"
|
|
5
|
+
require_relative "default_output"
|
|
6
|
+
require_relative "showcase_generator"
|
|
7
|
+
require_relative "clip/renderer"
|
|
8
|
+
require_relative "clip/toolchain"
|
|
9
|
+
|
|
10
|
+
module RubyLens
|
|
11
|
+
ClipResult = Data.define(:output_path, :showcase_path, :counts, :warnings) do
|
|
12
|
+
def to_payload = { output: output_path, showcase: showcase_path, counts: counts, warnings: warnings }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Generates a shareable MP4 of a project's showcase. The showcase HTML is
|
|
16
|
+
# written first (it is the render input and stays useful on its own), then
|
|
17
|
+
# headless Chrome and ffmpeg turn it into one seamless 1080p camera loop.
|
|
18
|
+
# The toolchain is checked before any indexing so a missing browser or
|
|
19
|
+
# ffmpeg fails in under a second with install guidance.
|
|
20
|
+
class ClipGenerator
|
|
21
|
+
DEFAULT_CLIP_NAME = "rubylens-clip.mp4"
|
|
22
|
+
# faststart parks the metadata (and so the marker) right behind ftyp, so
|
|
23
|
+
# every clip RubyLens writes matches within this head window.
|
|
24
|
+
MARKER_SCAN_HEAD_BYTES = 512 * 1024
|
|
25
|
+
|
|
26
|
+
def initialize(path: Dir.pwd, output: nil, lockfile: nil, details: false, progress: nil, renderer: nil)
|
|
27
|
+
@path = path
|
|
28
|
+
@output = output
|
|
29
|
+
@lockfile = lockfile
|
|
30
|
+
@details = details
|
|
31
|
+
@progress = progress
|
|
32
|
+
@renderer = renderer
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
renderer = @renderer || build_renderer
|
|
37
|
+
root = File.realpath(@path)
|
|
38
|
+
output, showcase_output = resolve_outputs(root)
|
|
39
|
+
showcase_result = ShowcaseGenerator.new(
|
|
40
|
+
path: @path, output: showcase_output, lockfile: @lockfile, details: @details,
|
|
41
|
+
).call
|
|
42
|
+
render_atomically(renderer, showcase_result.output_path, output)
|
|
43
|
+
|
|
44
|
+
ClipResult.new(
|
|
45
|
+
output_path: output,
|
|
46
|
+
showcase_path: showcase_result.output_path,
|
|
47
|
+
counts: showcase_result.counts,
|
|
48
|
+
warnings: showcase_result.warnings,
|
|
49
|
+
)
|
|
50
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP => error
|
|
51
|
+
raise Error, error.message
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_renderer
|
|
57
|
+
Clip::Renderer.new(
|
|
58
|
+
chrome: Clip::Toolchain.chrome_path,
|
|
59
|
+
ffmpeg: Clip::Toolchain.ffmpeg_path,
|
|
60
|
+
progress: @progress,
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# With no --output, both artifacts use their default names, Git-excluded
|
|
65
|
+
# locally, and an existing default clip is only replaced if it is ours.
|
|
66
|
+
# With --output FILE.mp4, the MP4 is written exactly where requested, like
|
|
67
|
+
# other custom output paths; the derived FILE.html companion was never
|
|
68
|
+
# named by the user, so an unrelated existing file there is refused.
|
|
69
|
+
def resolve_outputs(root)
|
|
70
|
+
if @output.nil?
|
|
71
|
+
default = DefaultOutput.resolve(root: root, name: DEFAULT_CLIP_NAME, description: "clip") do |existing|
|
|
72
|
+
ArtifactMarker.present?(existing, Clip::Renderer::MARKER_COMMENT, head_bytes: MARKER_SCAN_HEAD_BYTES)
|
|
73
|
+
end
|
|
74
|
+
return [default, nil]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Expanded once here so the guard, the writer, and the reported result
|
|
78
|
+
# all refer to the same real paths.
|
|
79
|
+
output = File.expand_path(@output)
|
|
80
|
+
companion = showcase_companion_path(output)
|
|
81
|
+
if File.exist?(companion) && !ArtifactMarker.present?(companion, ShowcaseWriter::MARKER)
|
|
82
|
+
raise Error, "clip companion path #{companion} already exists and is not a RubyLens showcase"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
[output, companion]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def showcase_companion_path(output)
|
|
89
|
+
base = output.sub(/\.mp4\z/i, "")
|
|
90
|
+
base = output if base.empty?
|
|
91
|
+
"#{base}.html"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def render_atomically(renderer, showcase_html, output)
|
|
95
|
+
AtomicOutput.replace(output) do |temporary|
|
|
96
|
+
renderer.render(showcase_html: showcase_html, output: temporary)
|
|
97
|
+
rescue Error => error
|
|
98
|
+
raise Error, "#{error.message} (the showcase HTML was still written to #{showcase_html})"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "git_repository"
|
|
5
|
+
|
|
6
|
+
module RubyLens
|
|
7
|
+
# Safety policy shared by every generator's default output path: an existing
|
|
8
|
+
# file is only replaced when the given check recognizes it as our own kind of
|
|
9
|
+
# artifact, and the path plus its temporary-file pattern are added to the
|
|
10
|
+
# repository's local Git excludes.
|
|
11
|
+
module DefaultOutput
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def resolve(root:, name:, description:)
|
|
15
|
+
output = File.join(root, name)
|
|
16
|
+
if File.exist?(output) && !yield(output)
|
|
17
|
+
raise Error, "default #{description} path already exists and is not a RubyLens #{description}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
GitRepository.new(root).exclude_local(output, description: description)
|
|
21
|
+
output
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/rubylens/generator.rb
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "artifact_marker"
|
|
4
|
+
require_relative "default_output"
|
|
5
|
+
|
|
3
6
|
module RubyLens
|
|
4
|
-
Result = Data.define(:output_path, :counts, :warnings)
|
|
7
|
+
Result = Data.define(:output_path, :counts, :warnings) do
|
|
8
|
+
def to_payload = { output: output_path, counts: counts, warnings: warnings }
|
|
9
|
+
end
|
|
5
10
|
|
|
6
11
|
class GenerationPipeline
|
|
7
12
|
def initialize(root:, lockfile: nil)
|
|
@@ -32,17 +37,11 @@ module RubyLens
|
|
|
32
37
|
|
|
33
38
|
def call
|
|
34
39
|
root = File.realpath(@path)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if output.nil?
|
|
38
|
-
output = File.join(root, DEFAULT_REPORT_NAME)
|
|
39
|
-
if File.exist?(output) && !report_writer.rubylens_report?(output)
|
|
40
|
-
raise Error, "default report path already exists and is not a RubyLens report"
|
|
41
|
-
end
|
|
42
|
-
GitRepository.new(root).exclude_local(output)
|
|
40
|
+
output = @output || DefaultOutput.resolve(root: root, name: DEFAULT_REPORT_NAME, description: "report") do |existing|
|
|
41
|
+
ArtifactMarker.present?(existing, ReportWriter::MARKER)
|
|
43
42
|
end
|
|
44
43
|
model, warnings = GenerationPipeline.new(root:, lockfile: @lockfile).call
|
|
45
|
-
output_path =
|
|
44
|
+
output_path = ReportWriter.new.write(model, output: output)
|
|
46
45
|
|
|
47
46
|
RubyLens::Result.new(output_path: output_path, counts: model.fetch("totals").freeze, warnings:)
|
|
48
47
|
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP => error
|