btape 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83f37a580ea032f35ec04083a301339d43bb35bd13881799774d275527558e43
4
- data.tar.gz: 6b47a03f994310102fc3534c621d7fe78e782e6ab39653a6c227b56838ba17c0
3
+ metadata.gz: 18261c2d64fe2120e746ab4e2991a85d8c6327aa32ad497125e6b8b1efb3779a
4
+ data.tar.gz: aa731da95d316a63404d3ae6a89f03d3c435bea95ff26eeda824c699f8390ef7
5
5
  SHA512:
6
- metadata.gz: 493ebb303b92ba896387e67236c61deaf5cfaed5ba746990e67c9be9ad06d88d1ea34985d3719a87b6edfbbafe06c59b8d052720b05471fc1215589db23da43d
7
- data.tar.gz: b5cf6dcfc0995a623f080f30bd5903dc8fed6f456d42f697534a720494d7c8418bcfdeb3917cb0c4537120216382578456642671b849d07b2365c486e9f0a329
6
+ metadata.gz: dbb660a8f316b473a9c0a62e9842f3cc131e28d5061b49227cdc7b474be90abae39a7dfd5104e5cca26931458e60140a3423c41043de72a2d0e27b2adb51e155
7
+ data.tar.gz: 89ddb3f16ff21c0710dce00bfdef3a5588e3ee70a0d7df29373afb32b8e0dbf976ba30f63b83971aedd460ad8ff8c16b980f086f0d58f8d4699fe335554c1144
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # btape
2
2
 
3
+ <p align="center">
4
+ <img src="assets/logo.png" alt="btape logo" width="480">
5
+ </p>
6
+
3
7
  `btape` is a small, VHS-inspired Ruby CLI that runs browser actions from a
4
8
  `.tape` file and records them as an animated GIF. Ferrum controls Chromium
5
9
  and captures PNG frames, and a pure-Ruby encoder produces the GIF. It
@@ -10,9 +14,16 @@ does not require Playwright, Selenium, ffmpeg, or an external service.
10
14
  ```text
11
15
  Output <path>
12
16
  Viewport <width>x<height>
17
+ Set <name> <value>
13
18
  Goto <url>
14
19
  Click <CSS selector or text=Text>
15
20
  Type <CSS selector> <text>
21
+ Press <key> [count]
22
+ Frame <CSS selector or main>
23
+ Evaluate <javascript>
24
+ WaitFor <CSS selector or text=Text> [duration]
25
+ WaitForJS <javascript> [duration]
26
+ Screenshot [name]
16
27
  Sleep <number>ms|s
17
28
  ```
18
29
 
@@ -29,6 +40,37 @@ Type "#email" "demo@example.com"
29
40
  Sleep 1s
30
41
  ```
31
42
 
43
+ `Evaluate` runs JavaScript in the current frame, which is how a tape reaches
44
+ an API the page exposes rather than clicking at it. `Frame` points the
45
+ commands that follow at an iframe, and `Frame main` returns to the page;
46
+ navigating returns to the page too, since the frame belonged to the page that
47
+ was left. `WaitFor` and `WaitForJS` poll instead of guessing at a `Sleep`.
48
+
49
+ `Screenshot` captures a frame there and then. With a name it also lands at a
50
+ predictable path, for picking one particular frame out of a run.
51
+
52
+ ## Settings
53
+
54
+ `Set NAME VALUE` configures a run. Every setting can also be given on the
55
+ command line with `--set NAME=VALUE`, which wins over the tape, so one tape
56
+ can run in more than one place.
57
+
58
+ | Name | Default | Meaning |
59
+ | --- | --- | --- |
60
+ | `WsUrl` | — | Connect to a browser already running at this CDP url instead of launching one |
61
+ | `CaptureMode` | `interval` | `interval` records continuously; `manual` captures only where `Screenshot` says to |
62
+ | `Framerate` | `10` | Captures per second in interval mode |
63
+ | `FrameDelay` | `100ms` | How long each frame is shown in the GIF |
64
+ | `Loop` | `0` | Times to loop; 0 is forever |
65
+ | `Scale` | `1.0` | Scale the output down |
66
+ | `OutputWidth` | — | Output width in pixels; overrides `Scale` and keeps the aspect ratio |
67
+ | `Quantizer` | `adaptive` | `adaptive` picks the palette from the frames; `rgb332` uses a fixed one |
68
+ | `Timeout` | `120s` | Give up on the whole run after this |
69
+ | `WaitTimeout` | `10s` | Give up on a `WaitFor` or `WaitForJS` after this |
70
+ | `WaitInterval` | `100ms` | How often those two check |
71
+ | `WaitStable` | `1` | How many checks in a row must pass before a wait is satisfied |
72
+ | `MaxFrames` | `600` | Stop rather than record a hung page until the disk fills |
73
+
32
74
  ## Install and run
33
75
 
34
76
  Chromium must be installed and discoverable by Ferrum. Then:
@@ -39,9 +81,79 @@ bundle exec btape demo.tape
39
81
  bundle exec rake spec
40
82
  ```
41
83
 
42
- The recording interval is 100 ms (10 fps). Temporary PNG frames are removed
43
- after a successful run. They are also isolated in the system temporary
44
- directory and cleaned when an error unwinds the run.
84
+ ```text
85
+ Usage: btape [options] SCRIPT.tape
86
+
87
+ --ws-url URL Connect to a browser already running at this CDP url
88
+ --set NAME=VALUE Override a setting, as a Set line would
89
+ --frames-dir DIR Write the PNG frames here and keep them
90
+ --verbose Report each command on stderr as it runs
91
+ ```
92
+
93
+ `BTAPE_WS_URL` is used when neither `--ws-url` nor `--set WsUrl=` is given.
94
+
95
+ ### A browser running somewhere else
96
+
97
+ btape launches its own Chromium by default. Point it at one that is already
98
+ running — a `browserless`/`chrome` container, say — and no browser needs to be
99
+ in the image btape runs from:
100
+
101
+ ```sh
102
+ btape --ws-url ws://chrome:3000 examples/thumbnails.tape
103
+ ```
104
+
105
+ Each connection gets its own browser context, so concurrent runs against one
106
+ shared browser do not see each other. The viewport is applied over the wire,
107
+ since a browser that is already running cannot be told its window size at
108
+ launch.
109
+
110
+ ### Frames, not just the GIF
111
+
112
+ Frames are normally written to a temporary directory and removed as the run
113
+ unwinds. `--frames-dir` keeps them:
114
+
115
+ ```sh
116
+ btape --frames-dir frames examples/thumbnails.tape
117
+ ```
118
+
119
+ ## From Ruby
120
+
121
+ `Runner#run` returns a `Btape::Result`:
122
+
123
+ ```ruby
124
+ commands = Btape::Parser.new.parse(File.read('deck.tape'))
125
+
126
+ result = Btape::Runner.new(logger: Rails.logger).run(
127
+ commands,
128
+ base_directory: File.dirname('deck.tape'),
129
+ settings: { ws_url: ENV['CHROME_WS_URL'] },
130
+ frames_directory: frames,
131
+ on_frame: ->(path, index) { logger.debug("captured #{index}: #{path}") }
132
+ )
133
+
134
+ result.output_path # where the GIF went
135
+ result.frame_count # frames that went into it
136
+ result.frame_paths # the frames, when frames_directory was given
137
+ result.named_frames['page-01'] # the frame a Screenshot named
138
+ ```
139
+
140
+ Nothing has to touch the filesystem. Pass an IO to write the GIF into:
141
+
142
+ ```ruby
143
+ buffer = StringIO.new(+''.b)
144
+ Btape::Runner.new.run(commands, base_directory: '.', output: buffer)
145
+
146
+ # `buffer.string` is the GIF. Hand it to whatever holds on to it — an Active
147
+ # Storage attachment on one of your own records, say:
148
+ deck = Deck.find(params[:id])
149
+ deck.animation.attach(io: StringIO.new(buffer.string), filename: 'deck.gif')
150
+ ```
151
+
152
+ or use the encoder on its own, with PNG paths or ChunkyPNG images:
153
+
154
+ ```ruby
155
+ Btape::GifEncoder.new(delay: 150, width: 640).encode(frame_paths) # => String
156
+ ```
45
157
 
46
158
  ## Container development with dip or wip
47
159
 
@@ -60,11 +172,31 @@ wip dispatch btape examples/demo.tape
60
172
  `examples/demo.tape` drives a small static page bundled at
61
173
  `examples/demo_app.html`, so the demo is self-contained and needs no other
62
174
  service running. Edit the tape (or point `Goto` at a different URL) to record
63
- something else.
175
+ something else. `examples/thumbnails.tape` shows the other shape of run: one
176
+ frame per page of a deck, against a browser running elsewhere.
177
+
178
+ ## Limitations
179
+
180
+ The palette is chosen from the frames being encoded, which tracks gradients
181
+ and text edges far more closely than the fixed RGB332 palette earlier versions
182
+ used — but banding compresses well and fidelity does not, so the files are
183
+ larger than they were. `Set Scale` or `Set OutputWidth` are the levers to pull
184
+ back; identical consecutive frames are already collapsed into one held for
185
+ longer. `Set Quantizer rgb332` restores the old palette.
186
+
187
+ The first matching element is used for `Click` and `Type`.
188
+
189
+ ## Upgrading to 0.2
190
+
191
+ `Runner#run` returns a `Btape::Result` rather than the output path. Read
192
+ `result.output_path` where the path was used before.
193
+
194
+ ## Contributing
64
195
 
65
- ## MVP limitations
196
+ Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md)
197
+ for the development setup, PR conventions, and how releases are generated.
198
+ This project follows the [Code of Conduct](CODE_OF_CONDUCT.md).
66
199
 
67
- The GIF encoder uses a fixed 256-colour RGB332 palette to stay dependency-light.
68
- This favors portability over photographic colour fidelity and file size. The
69
- first matching element is used for `Click` and `Type`.
200
+ ## License
70
201
 
202
+ [MIT](LICENSE)
data/lib/btape/cli.rb CHANGED
@@ -1,26 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
4
+ require 'optparse'
5
+
3
6
  module Btape
4
7
  # Entry point invoked by the `btape` executable: parses argv, runs the
5
8
  # script, and reports success or failure.
6
9
  class CLI
7
- def initialize(out: $stdout, err: $stderr, runner: Runner.new)
10
+ Options = Struct.new(:help, :settings, :frames_directory, :verbose)
11
+
12
+ USAGE = 'Usage: btape [options] SCRIPT.tape'
13
+ HELP_ARGUMENTS = %w[help -h --help].freeze
14
+ HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
15
+ 'Type SELECTOR TEXT', 'Sleep DURATION', 'Set NAME VALUE', 'Screenshot [NAME]',
16
+ 'Evaluate JAVASCRIPT', 'WaitFor SELECTOR [TIMEOUT]',
17
+ 'WaitForJS JAVASCRIPT [TIMEOUT]', 'Frame SELECTOR|main',
18
+ 'Press KEY [COUNT]'].freeze
19
+ # The two ways a flag can name a browser: --ws-url and --set WsUrl=.
20
+ WS_URL_KEYS = [:ws_url, 'WsUrl'].freeze
21
+
22
+ # The runner is built after the flags are read, so that --verbose can
23
+ # reach it. Pass one to use it as given.
24
+ def initialize(out: $stdout, err: $stderr, runner: nil)
8
25
  @out = out
9
26
  @err = err
10
27
  @runner = runner
11
28
  end
12
29
 
13
- HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
14
- 'Type SELECTOR TEXT', 'Sleep DURATION'].freeze
15
-
16
30
  def run(argv)
17
- return print_help if argv.empty? || %w[help -h --help].include?(argv.first)
18
- raise Error, 'usage: btape SCRIPT.tape' unless argv.length == 1
31
+ options = Options.new(false, {}, nil, false)
32
+ arguments = parse_options(argv, options)
33
+ return print_help if options.help || arguments.empty? || HELP_ARGUMENTS.include?(arguments.first)
34
+ raise Error, USAGE unless arguments.length == 1
19
35
 
20
- script = File.expand_path(argv.first)
21
- commands = Parser.new.parse(File.read(script))
22
- output = @runner.run(commands, base_directory: File.dirname(script))
23
- @out.puts "Created #{output}"
36
+ record(arguments.first, options)
24
37
  0
25
38
  rescue Error, SystemCallError => e
26
39
  @err.puts "btape: #{e.message}"
@@ -29,11 +42,88 @@ module Btape
29
42
 
30
43
  private
31
44
 
45
+ def record(argument, options)
46
+ script = File.expand_path(argument)
47
+ commands = Parser.new.parse(File.read(script))
48
+ result = runner(options).run(
49
+ commands,
50
+ base_directory: File.dirname(script),
51
+ settings: settings(options),
52
+ frames_directory: options.frames_directory
53
+ )
54
+ @out.puts "Created #{result.output_path}"
55
+ report_frames(result)
56
+ end
57
+
58
+ def runner(options)
59
+ @runner || Runner.new(logger: logger(options))
60
+ end
61
+
62
+ def logger(options)
63
+ return NullLogger.new unless options.verbose
64
+
65
+ Logger.new(@err, level: Logger::DEBUG, formatter: ->(_severity, _time, _program, message) { "#{message}\n" })
66
+ end
67
+
68
+ def report_frames(result)
69
+ return if result.frame_paths.empty?
70
+
71
+ @out.puts "Kept #{result.frame_paths.length} frame(s) in #{File.dirname(result.frame_paths.first)}"
72
+ end
73
+
74
+ # The tape's own `Set` lines are the baseline; these flags override them,
75
+ # which is what lets one tape run against a local and a remote browser.
76
+ #
77
+ # BTAPE_WS_URL is the last resort, and only applies when neither flag named
78
+ # a browser. Settings resolves --ws-url and --set WsUrl to the same
79
+ # attribute, so merging the environment in unconditionally would let it
80
+ # overwrite the --set value rather than fall back to it.
81
+ def settings(options)
82
+ return options.settings if WS_URL_KEYS.any? { |key| options.settings.key?(key) }
83
+
84
+ options.settings.merge(ws_url: ENV.fetch('BTAPE_WS_URL', nil))
85
+ end
86
+
87
+ def parse_options(argv, options)
88
+ option_parser(options).parse(argv)
89
+ rescue OptionParser::ParseError => e
90
+ raise Error, e.message
91
+ end
92
+
93
+ def option_parser(options)
94
+ OptionParser.new do |parser|
95
+ parser.banner = USAGE
96
+ parser.on('--ws-url URL', 'Connect to a browser already running at this CDP url') do |url|
97
+ options.settings[:ws_url] = url
98
+ end
99
+ parser.on('--set NAME=VALUE', 'Override a setting, as a Set line would') do |pair|
100
+ name, value = pair.split('=', 2)
101
+ raise Error, '--set expects NAME=VALUE' if value.nil?
102
+
103
+ options.settings[name] = value
104
+ end
105
+ parser.on('--frames-dir DIR', 'Write the PNG frames here and keep them') do |directory|
106
+ options.frames_directory = directory
107
+ end
108
+ parser.on('--verbose', 'Report each command on stderr as it runs') { options.verbose = true }
109
+ parser.on('-h', '--help', 'Show this message') { options.help = true }
110
+ end
111
+ end
112
+
32
113
  def print_help
33
- @out.puts 'Usage: btape SCRIPT.tape'
114
+ @out.puts USAGE
115
+ @out.puts
116
+ @out.puts 'Options:'
117
+ @out.puts ' --ws-url URL Connect to a browser already running at this CDP url'
118
+ @out.puts ' --set NAME=VALUE Override a setting, as a Set line would'
119
+ @out.puts ' --frames-dir DIR Write the PNG frames here and keep them'
120
+ @out.puts ' --verbose Report each command on stderr as it runs'
34
121
  @out.puts
35
122
  @out.puts 'Commands:'
36
123
  HELP_COMMANDS.each { |command| @out.puts " #{command}" }
124
+ @out.puts
125
+ @out.puts 'Settings:'
126
+ Settings::DEFINITIONS.each_key { |name| @out.puts " #{name}" }
37
127
  0
38
128
  end
39
129
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Btape
4
+ # The duration literals .tape scripts use ("500ms", "1.5s"). The parser,
5
+ # the runner and Settings all need the same format, so it lives here once
6
+ # rather than as a regexp repeated at each call site.
7
+ module Duration
8
+ PATTERN = /\A(\d+(?:\.\d+)?)(ms|s)\z/
9
+ DESCRIPTION = 'must use ms or s (for example, 500ms or 1.5s)'
10
+
11
+ module_function
12
+
13
+ def valid?(value)
14
+ PATTERN.match?(value.to_s)
15
+ end
16
+
17
+ # Returns the duration in seconds.
18
+ def parse(value)
19
+ match = PATTERN.match(value.to_s)
20
+ raise ArgumentError, "duration #{DESCRIPTION}" unless match
21
+
22
+ amount, unit = match.captures
23
+ amount.to_f / (unit == 'ms' ? 1000 : 1)
24
+ end
25
+ end
26
+ end
data/lib/btape/error.rb CHANGED
@@ -3,6 +3,10 @@
3
3
  module Btape
4
4
  class Error < StandardError; end
5
5
 
6
+ # Raised when a run outlasts Set Timeout. A page that never finishes
7
+ # loading would otherwise record until the disk filled.
8
+ class TimeoutError < Error; end
9
+
6
10
  # Raised for a problem in the .tape script itself, carrying the line
7
11
  # number so the CLI can report where the script went wrong.
8
12
  class ScriptError < Error
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require_relative 'duration'
5
+ require_relative 'null_logger'
6
+
7
+ module Btape
8
+ # Performs the parsed commands against a browser, turning any failure into a
9
+ # ScriptError that points back at the line it came from.
10
+ #
11
+ # It lives apart from Runner because Runner's job is the recording session
12
+ # around the script — the browser, the frames, the GIF — while this is the
13
+ # script itself, and only this grows with each new command.
14
+ class Executor
15
+ # Commands that do something at run time, and the method that does it.
16
+ # Output, Viewport and Set are read before the run starts and have no
17
+ # behaviour of their own here.
18
+ HANDLERS = {
19
+ 'Goto' => :goto, 'Click' => :click, 'Type' => :enter, 'Sleep' => :pause,
20
+ 'Screenshot' => :screenshot, 'Evaluate' => :evaluate,
21
+ 'WaitFor' => :wait_for_element, 'WaitForJS' => :wait_for_js,
22
+ 'Frame' => :frame, 'Press' => :press
23
+ }.freeze
24
+
25
+ MAIN_FRAME = 'main'
26
+
27
+ def initialize(browser:, recorder:, settings:, lock: Monitor.new, logger: NullLogger.new)
28
+ @browser = browser
29
+ # Elements and JavaScript are looked up in the current frame, which
30
+ # starts as the page itself and moves when a Frame command says so.
31
+ @target = browser
32
+ @recorder = recorder
33
+ @settings = settings
34
+ @lock = lock
35
+ @logger = logger
36
+ end
37
+
38
+ def call(commands)
39
+ commands.each do |command|
40
+ @logger.debug("btape: line #{command.line_number}: #{command.name}")
41
+ perform(command)
42
+ # The run's own deadline arrives asynchronously and usually lands inside
43
+ # a command, which would otherwise be reported as that command failing.
44
+ # A caller has to be able to tell "the run outlasted Set Timeout" from
45
+ # "this line is broken", so it goes out as it came in.
46
+ rescue TimeoutError
47
+ raise
48
+ rescue StandardError => e
49
+ raise ScriptError.new(command.line_number, "#{command.name} failed: #{e.message}")
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # Held across each exchange with the browser, and shared with the
56
+ # recorder, so a screenshot taken on the recorder's thread is never in
57
+ # flight at the same time as a command on this one.
58
+ #
59
+ # It is deliberately not held for a whole command: Sleep and the waiting
60
+ # commands spend most of their time not talking to the browser at all,
61
+ # and holding the lock through that would leave interval recording with
62
+ # nothing to capture for the duration.
63
+ def locked(&)
64
+ @lock.synchronize(&)
65
+ end
66
+
67
+ def perform(command)
68
+ handler = HANDLERS[command.name]
69
+ send(handler, *command.arguments) if handler
70
+ end
71
+
72
+ def goto(url)
73
+ locked { @browser.go_to(url) }
74
+ # Whatever frame we were in belongs to the page we just left.
75
+ @target = @browser
76
+ end
77
+
78
+ # Points the following commands at an iframe, so a tape can reach an API
79
+ # inside it — a slide deck's own navigation, say — rather than only what
80
+ # the outer document exposes. Nesting works by switching again from
81
+ # within, and `Frame main` returns to the page.
82
+ def frame(selector)
83
+ return @target = @browser if selector == MAIN_FRAME
84
+
85
+ # Reading .frame off the node is a further exchange with the browser, so
86
+ # it belongs under the lock as much as the lookup that found the node.
87
+ node = find(selector)
88
+ @target = locked { node.frame } || raise("#{selector} is not a frame")
89
+ end
90
+
91
+ def press(key, count = '1')
92
+ Integer(count, 10).times { locked { @browser.keyboard.type(key.to_sym) } }
93
+ end
94
+
95
+ def click(selector)
96
+ locked { find(selector).click }
97
+ end
98
+
99
+ def enter(selector, text)
100
+ locked do
101
+ element = find(selector)
102
+ element.focus
103
+ element.type(text)
104
+ end
105
+ end
106
+
107
+ def pause(duration)
108
+ sleep(Duration.parse(duration))
109
+ end
110
+
111
+ def screenshot(name = nil)
112
+ @recorder.capture(name: name)
113
+ end
114
+
115
+ def evaluate(expression)
116
+ locked { @target.evaluate(expression) }
117
+ end
118
+
119
+ def wait_for_element(selector, timeout = nil)
120
+ wait_until(timeout, "#{selector} to appear") { element(selector) }
121
+ end
122
+
123
+ def wait_for_js(expression, timeout = nil)
124
+ wait_until(timeout, "#{expression} to be true") { evaluate(expression) }
125
+ end
126
+
127
+ # Polls until the block has been satisfied WaitStable times in a row. The
128
+ # streak matters for pages that report readiness before they have settled:
129
+ # a single true reading can land mid-render, several in a row cannot.
130
+ #
131
+ # A block that raises counts as not-yet-satisfied, since a page part-way
132
+ # through loading will happily throw on a property that is about to
133
+ # exist. The last error is reported if the wait times out, so a broken
134
+ # expression still surfaces rather than being silently polled forever.
135
+ def wait_until(timeout, description)
136
+ timeout = timeout ? Duration.parse(timeout) : @settings.wait_timeout
137
+ deadline = monotonic + timeout
138
+ stable = 0
139
+ failure = nil
140
+
141
+ loop do
142
+ satisfied = begin
143
+ yield
144
+ rescue StandardError => e
145
+ failure = e
146
+ false
147
+ end
148
+
149
+ stable = satisfied ? stable + 1 : 0
150
+ return if stable >= @settings.wait_stable
151
+ raise Error, timed_out(description, timeout, failure) if monotonic >= deadline
152
+
153
+ sleep(@settings.wait_interval)
154
+ end
155
+ end
156
+
157
+ def timed_out(description, timeout, failure)
158
+ message = "timed out after #{timeout}s waiting for #{description}"
159
+ failure ? "#{message} (last error: #{failure.message})" : message
160
+ end
161
+
162
+ def monotonic
163
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
164
+ end
165
+
166
+ def find(selector)
167
+ element(selector) || raise("element not found: #{selector}")
168
+ end
169
+
170
+ def element(selector)
171
+ locked do
172
+ next @target.at_css(selector) unless selector.start_with?('text=')
173
+
174
+ literal = xpath_literal(selector.delete_prefix('text='))
175
+ @target.at_xpath("//*[normalize-space(text())=#{literal}]")
176
+ end
177
+ end
178
+
179
+ def xpath_literal(text)
180
+ return %("#{text}") unless text.include?('"')
181
+
182
+ parts = text.split('"', -1).map { |part| %("#{part}") }
183
+ "concat(#{parts.join(%q(, '"', ))})"
184
+ end
185
+ end
186
+ end