charai 0.1.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,170 @@
1
+ module Charai
2
+ class BrowserLauncher
3
+ def initialize
4
+ if ::Charai::Util.macos?
5
+ if File.exist?("/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox")
6
+ @firefox_executable_path = "/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox"
7
+ end
8
+ elsif ::Charai::Util.linux?
9
+ if File.exist?("/usr/bin/firefox-devedition")
10
+ @firefox_executable_path = "/usr/bin/firefox-devedition"
11
+ end
12
+ end
13
+
14
+ raise 'Firefox Developer Edition is not found.' unless @firefox_executable_path
15
+ end
16
+
17
+ def launch(headless: false, debug_protocol: false)
18
+ tmpdir = Dir.mktmpdir('charai')
19
+ create_user_profile(tmpdir)
20
+
21
+ args = [
22
+ "--remote-debugging-port=0",
23
+ "--profile",
24
+ tmpdir,
25
+ "--no-remote",
26
+ ]
27
+ if ::Charai::Util.macos?
28
+ args << "--foreground"
29
+ end
30
+ if headless
31
+ args << "--headless"
32
+ end
33
+
34
+ proc = BrowserProcess.new(
35
+ @firefox_executable_path,
36
+ *args,
37
+ "about:blank",
38
+ )
39
+ at_exit do
40
+ proc.kill
41
+ FileUtils.remove_entry(tmpdir)
42
+ end
43
+ trap(:INT) { proc.kill ; exit 130 }
44
+ trap(:TERM) { proc.kill ; proc.dispose }
45
+ trap(:HUP) { proc.kill ; proc.dispose }
46
+
47
+ endpoint = wait_for_ws_endpoint(proc)
48
+
49
+ web_socket = WebSocket.new(url: "#{endpoint}/session")
50
+ Browser.new(web_socket, debug_protocol: debug_protocol)
51
+ end
52
+
53
+ private
54
+
55
+ def create_user_profile(profile_dir)
56
+ open(File.join(profile_dir, 'user.js'), 'w') do |f|
57
+ f.write(template_for_user_profile)
58
+ end
59
+ end
60
+
61
+ def template_for_user_profile
62
+ # execute the code below to create a template of user profile for Firefox.
63
+ # -----------
64
+ # import { createProfile } from '@puppeteer/browsers';
65
+ #
66
+ # await createProfile('firefox', {
67
+ # path: './my_prefs',
68
+ # preferences: {
69
+ # 'remote.active-protocols': 1,
70
+ # 'fission.webContentIsolationStrategy': 0,
71
+ # }
72
+ # })
73
+ <<~JS
74
+ user_pref("app.normandy.api_url", "");
75
+ user_pref("app.update.checkInstallTime", false);
76
+ user_pref("app.update.disabledForTesting", true);
77
+ user_pref("apz.content_response_timeout", 60000);
78
+ user_pref("browser.contentblocking.features.standard", "-tp,tpPrivate,cookieBehavior0,-cm,-fp");
79
+ user_pref("browser.dom.window.dump.enabled", true);
80
+ user_pref("browser.newtabpage.activity-stream.feeds.system.topstories", false);
81
+ user_pref("browser.newtabpage.enabled", false);
82
+ user_pref("browser.pagethumbnails.capturing_disabled", true);
83
+ user_pref("browser.safebrowsing.blockedURIs.enabled", false);
84
+ user_pref("browser.safebrowsing.downloads.enabled", false);
85
+ user_pref("browser.safebrowsing.malware.enabled", false);
86
+ user_pref("browser.safebrowsing.phishing.enabled", false);
87
+ user_pref("browser.search.update", false);
88
+ user_pref("browser.sessionstore.resume_from_crash", false);
89
+ user_pref("browser.shell.checkDefaultBrowser", false);
90
+ user_pref("browser.startup.homepage", "about:blank");
91
+ user_pref("browser.startup.homepage_override.mstone", "ignore");
92
+ user_pref("browser.startup.page", 0);
93
+ user_pref("browser.tabs.disableBackgroundZombification", false);
94
+ user_pref("browser.tabs.warnOnCloseOtherTabs", false);
95
+ user_pref("browser.tabs.warnOnOpen", false);
96
+ user_pref("browser.translations.automaticallyPopup", false);
97
+ user_pref("browser.uitour.enabled", false);
98
+ user_pref("browser.urlbar.suggest.searches", false);
99
+ user_pref("browser.usedOnWindows10.introURL", "");
100
+ user_pref("browser.warnOnQuit", false);
101
+ user_pref("datareporting.healthreport.documentServerURI", "http://dummy.test/dummy/healthreport/");
102
+ user_pref("datareporting.healthreport.logging.consoleEnabled", false);
103
+ user_pref("datareporting.healthreport.service.enabled", false);
104
+ user_pref("datareporting.healthreport.service.firstRun", false);
105
+ user_pref("datareporting.healthreport.uploadEnabled", false);
106
+ user_pref("datareporting.policy.dataSubmissionEnabled", false);
107
+ user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
108
+ user_pref("devtools.jsonview.enabled", false);
109
+ user_pref("dom.disable_open_during_load", false);
110
+ user_pref("dom.file.createInChild", true);
111
+ user_pref("dom.ipc.reportProcessHangs", false);
112
+ user_pref("dom.max_chrome_script_run_time", 0);
113
+ user_pref("dom.max_script_run_time", 0);
114
+ user_pref("extensions.autoDisableScopes", 0);
115
+ user_pref("extensions.enabledScopes", 5);
116
+ user_pref("extensions.getAddons.cache.enabled", false);
117
+ user_pref("extensions.installDistroAddons", false);
118
+ user_pref("extensions.screenshots.disabled", true);
119
+ user_pref("extensions.update.enabled", false);
120
+ user_pref("extensions.update.notifyUser", false);
121
+ user_pref("extensions.webservice.discoverURL", "http://dummy.test/dummy/discoveryURL");
122
+ user_pref("focusmanager.testmode", true);
123
+ user_pref("general.useragent.updates.enabled", false);
124
+ user_pref("geo.provider.testing", true);
125
+ user_pref("geo.wifi.scan", false);
126
+ user_pref("hangmonitor.timeout", 0);
127
+ user_pref("javascript.options.showInConsole", true);
128
+ user_pref("media.gmp-manager.updateEnabled", false);
129
+ user_pref("media.sanity-test.disabled", true);
130
+ user_pref("network.cookie.sameSite.laxByDefault", false);
131
+ user_pref("network.http.prompt-temp-redirect", false);
132
+ user_pref("network.http.speculative-parallel-limit", 0);
133
+ user_pref("network.manage-offline-status", false);
134
+ user_pref("network.sntp.pools", "dummy.test");
135
+ user_pref("plugin.state.flash", 0);
136
+ user_pref("privacy.trackingprotection.enabled", false);
137
+ user_pref("remote.enabled", true);
138
+ user_pref("security.certerrors.mitm.priming.enabled", false);
139
+ user_pref("security.fileuri.strict_origin_policy", false);
140
+ user_pref("security.notification_enable_delay", 0);
141
+ user_pref("services.settings.server", "http://dummy.test/dummy/blocklist/");
142
+ user_pref("signon.autofillForms", false);
143
+ user_pref("signon.rememberSignons", false);
144
+ user_pref("startup.homepage_welcome_url", "about:blank");
145
+ user_pref("startup.homepage_welcome_url.additional", "");
146
+ user_pref("toolkit.cosmeticAnimations.enabled", false);
147
+ user_pref("toolkit.startup.max_resumed_crashes", -1);
148
+ user_pref("remote.active-protocols", 1);
149
+ user_pref("fission.webContentIsolationStrategy", 0);
150
+ JS
151
+ end
152
+
153
+ def wait_for_ws_endpoint(browser_process)
154
+ lines = []
155
+ Timeout.timeout(30) do
156
+ loop do
157
+ line = browser_process.stderr.readline
158
+ /^WebDriver BiDi listening on (ws:\/\/.*)$/.match(line) do |m|
159
+ return m[1].gsub(/\r/, '')
160
+ end
161
+ lines << line
162
+ end
163
+ end
164
+ rescue EOFError
165
+ raise lines.join("\n")
166
+ rescue Timeout::Error
167
+ raise "Timed out after 30 seconds while trying to connect to the browser."
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,26 @@
1
+ require 'open3'
2
+
3
+ module Charai
4
+ class BrowserProcess
5
+ def initialize(*command)
6
+ stdin, @stdout, @stderr, @thread = Open3.popen3(*command, { pgroup: true })
7
+ stdin.close
8
+ @pid = @thread.pid
9
+ rescue Errno::ENOENT => err
10
+ raise LaunchError.new("Failed to launch browser process: #{err}")
11
+ end
12
+
13
+ def kill
14
+ Process.kill(:KILL, @pid)
15
+ rescue Errno::ESRCH
16
+ # already killed
17
+ end
18
+
19
+ def dispose
20
+ [@stdout, @stderr].each { |io| io.close unless io.closed? }
21
+ @thread.terminate
22
+ end
23
+
24
+ attr_reader :stdout, :stderr
25
+ end
26
+ end
@@ -0,0 +1,201 @@
1
+ require 'base64'
2
+
3
+ module Charai
4
+ class BrowsingContext
5
+ def initialize(browser, context_id)
6
+ @browser = browser
7
+ @context_id = context_id
8
+ end
9
+
10
+ def navigate(url, wait: :interactive)
11
+ bidi_call_async('browsingContext.navigate', {
12
+ url: url,
13
+ wait: wait,
14
+ }.compact).value!
15
+ end
16
+
17
+ def realms
18
+ result = bidi_call_async('script.getRealms').value!
19
+ result['realms'].map do |realm|
20
+ Realm.new(
21
+ browsing_context: self,
22
+ id: realm['realm'],
23
+ origin: realm['origin'],
24
+ type: realm['type'],
25
+ )
26
+ end
27
+ end
28
+
29
+ def default_realm
30
+ realms.find { |realm| realm.type == 'window' }
31
+ end
32
+
33
+ def activate
34
+ bidi_call_async('browsingContext.activate').value!
35
+ end
36
+
37
+ def capture_screenshot(origin: nil, format: nil, clip: nil)
38
+ result = bidi_call_async('browsingContext.captureScreenshot', {
39
+ origin: origin,
40
+ format: format,
41
+ clip: clip,
42
+ }.compact).value!
43
+
44
+ Base64.strict_decode64(result['data'])
45
+ end
46
+
47
+ def close(prompt_unload: nil)
48
+ bidi_call_async('browsingContext.close', {
49
+ promptUnload: prompt_unload,
50
+ }.compact).value!
51
+ end
52
+
53
+ def perform_keyboard_actions(&block)
54
+ q = ActionQueue.new
55
+ block.call(q)
56
+ perform_actions([{
57
+ type: 'key',
58
+ id: '__charai_keyboard',
59
+ actions: q.to_a,
60
+ }])
61
+ end
62
+
63
+ def perform_mouse_actions(&block)
64
+ q = ActionQueue.new
65
+ block.call(q)
66
+ perform_actions([{
67
+ type: 'pointer',
68
+ id: '__charai_mouse',
69
+ actions: q.to_a,
70
+ }])
71
+ end
72
+
73
+ def perform_mouse_wheel_actions(&block)
74
+ q = ActionQueue.new
75
+ block.call(q)
76
+ perform_actions([{
77
+ type: 'wheel',
78
+ id: '__charai_wheel',
79
+ actions: q.to_a,
80
+ }])
81
+ end
82
+
83
+ def reload(ignore_cache: nil, wait: nil)
84
+ bidi_call_async('browsingContext.reload', {
85
+ ignoreCache: ignore_cache,
86
+ wait: wait,
87
+ }.compact).value!
88
+ end
89
+
90
+ def set_viewport(width:, height:, device_pixel_ratio: nil)
91
+ bidi_call_async('browsingContext.setViewport', {
92
+ viewport: {
93
+ width: width,
94
+ height: height,
95
+ },
96
+ devicePixelRatio: device_pixel_ratio,
97
+ }.compact).value!
98
+ end
99
+
100
+ def traverse_history(delta)
101
+ bidi_call_async('browsingContext.traverseHistory', {
102
+ delta: delta,
103
+ }).value!
104
+ end
105
+
106
+ def url
107
+ @url
108
+ end
109
+
110
+ def _update_url(url)
111
+ @url = url
112
+ end
113
+
114
+ private
115
+
116
+ def bidi_call_async(method_, params = {})
117
+ @browser.bidi_call_async(method_, params.merge({ context: @context_id }))
118
+ end
119
+
120
+ def perform_actions(actions)
121
+ bidi_call_async('input.performActions', {
122
+ actions: actions,
123
+ }).value!
124
+ end
125
+
126
+ class Realm
127
+ def initialize(browsing_context:, id:, origin:, type: nil)
128
+ @browsing_context = browsing_context
129
+ @id = id
130
+ @origin = origin
131
+ @type = type
132
+ end
133
+
134
+ class ScriptEvaluationError < StandardError; end
135
+
136
+ def script_evaluate(expression)
137
+ result = @browsing_context.send(:bidi_call_async, 'script.evaluate', {
138
+ expression: expression,
139
+ target: { realm: @id },
140
+ awaitPromise: true,
141
+ }).value!
142
+
143
+ if result['type'] == 'exception'
144
+ raise ScriptEvaluationError, result['exceptionDetails']['text']
145
+ end
146
+
147
+ deserialize(result['result'])
148
+ end
149
+
150
+ attr_reader :type
151
+
152
+ private
153
+
154
+ # ref: https://github.com/puppeteer/puppeteer/blob/puppeteer-v23.5.3/packages/puppeteer-core/src/bidi/Deserializer.ts#L21
155
+ # Converted using ChatGPT 4o
156
+ def deserialize(result)
157
+ case result["type"]
158
+ when 'array'
159
+ result['value']&.map { |value| deserialize(value) }
160
+ when 'set'
161
+ result['value']&.each_with_object(Set.new) do |value, acc|
162
+ acc.add(deserialize(value))
163
+ end
164
+ when 'object'
165
+ result['value']&.each_with_object({}) do |tuple, acc|
166
+ key, value = tuple
167
+ acc[key] = deserialize(value)
168
+ end
169
+ when 'map'
170
+ result['value']&.each_with_object({}) do |tuple, acc|
171
+ key, value = tuple
172
+ acc[key] = deserialize(value)
173
+ end
174
+ when 'promise'
175
+ {}
176
+ when 'regexp'
177
+ flags = 0
178
+ result['value']['flags']&.each_char do |flag|
179
+ case flag
180
+ when 'm'
181
+ flags |= Regexp::MULTILINE
182
+ when 'i'
183
+ flags |= Regexp::IGNORECASE
184
+ end
185
+ end
186
+ Regexp.new(result['value']['pattern'], flags)
187
+ when 'date'
188
+ Date.parse(result['value'])
189
+ when 'undefined'
190
+ nil
191
+ when 'null'
192
+ nil
193
+ when 'number', 'bigint', 'boolean', 'string'
194
+ result['value']
195
+ else
196
+ raise ArgumentError, "Unknown type: #{result['type']}"
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charai
4
+ class Driver < ::Capybara::Driver::Base
5
+ def initialize(_app, **options)
6
+ @openai_configuration = options[:openai_configuration]
7
+ unless @openai_configuration
8
+ raise ArgumentError, "driver_options[:openai_configuration] is required"
9
+ end
10
+ @headless = options[:headless]
11
+ @callback = options[:callback]
12
+ @introduction = options[:introduction]
13
+ @debug_protocol = %w[1 true].include?(ENV['DEBUG'])
14
+ end
15
+
16
+ attr_writer :callback, :introduction, :additional_instruction
17
+
18
+ def wait?; false; end
19
+ def needs_server?; true; end
20
+
21
+ def <<(text)
22
+ agent << text
23
+ end
24
+
25
+ def last_message
26
+ agent.last_message
27
+ end
28
+
29
+ def reset!
30
+ @browsing_context&.close
31
+ @browsing_context = nil
32
+ @browser&.close
33
+ @browser = nil
34
+ @openai_chat&.clear
35
+ @agent = nil
36
+ @additional_instruction = nil
37
+ end
38
+
39
+ def visit(path)
40
+ host = Capybara.app_host || Capybara.default_host
41
+
42
+ url =
43
+ if host
44
+ Addressable::URI.parse(host) + path
45
+ else
46
+ path
47
+ end
48
+
49
+ browsing_context.navigate(url)
50
+ end
51
+
52
+ private
53
+
54
+ def browser
55
+ @browser ||= Browser.launch(headless: @headless, debug_protocol: @debug_protocol)
56
+ end
57
+
58
+ def browsing_context
59
+ @browsing_context ||= browser.create_browsing_context.tap do |context|
60
+ context.set_viewport(width: 1024, height: 800)
61
+ end
62
+ end
63
+
64
+ def openai_chat
65
+ @openai_chat ||= OpenaiChat.new(
66
+ @openai_configuration,
67
+ introduction: @introduction || default_introduction,
68
+ callback: @callback,
69
+ )
70
+ end
71
+
72
+ def agent
73
+ @agent ||= Agent.new(
74
+ input_tool: InputTool.new(browsing_context, callback: @callback),
75
+ openai_chat: openai_chat,
76
+ )
77
+ end
78
+
79
+ def default_introduction
80
+ <<~MARKDOWN
81
+ あなたはWebサイトの試験が得意なテスターです。Rubyのコードを使ってブラウザを自動操作する方法にも詳しいです。
82
+
83
+ ブラウザを操作する方法は以下の内容です。
84
+
85
+ * 画面の左上から (10ピクセル, 20ピクセル)の位置をクリックしたい場合には `driver.click(x: 10, y: 20)`
86
+ * キーボードで "hogeHoge!!" と入力したい場合には `driver.type_text("hogeHoge!!")`
87
+ * Enterキーを押したい場合には `driver.press_key("Enter")`
88
+ * コピー&ペーストをしたい場合には `driver.on_pressing_key("CtrlOrMeta") { driver.press_key("c") ; driver.press_key("v") }`
89
+ * 画面の左上から (10ピクセル, 20ピクセル)の位置にマウスを置いてスクロール操作で下に向かってスクロールをしたい場合には `driver.scroll_down(x: 10, y: 20, velocity: 1500)`
90
+ * 同様に、上に向かってスクロールをしたい場合には `driver.scroll_up(x: 10, y: 20, velocity: 1500)`
91
+ * 画面が切り替わるまで2秒待ちたい場合には `driver.sleep_seconds(2)`
92
+ * 現在の画面を一旦確認したい場合には `driver.capture_screenshot`
93
+ * DOM要素の位置を確認するために、JavaScriptの実行結果を取得したい場合は `driver.execute_script('JSON.stringify(document.querySelector("#some").getBoundingClientRect())')`
94
+ * テスト項目1がOKの場合には `driver.assertion_ok("テスト項目1")` 、テスト項目2がNGの場合には `driver.assertion_fail("テスト項目2")`
95
+
96
+ 例えば、class="login"のテキストボックスの場所を特定したい場合には
97
+
98
+ ```
99
+ driver.execute_script('JSON.stringify(document.querySelector("input.login").getBoundingClientRect())')
100
+ ```
101
+
102
+ そうすると、私が以下のように実行結果を返します。
103
+
104
+ ```
105
+ {"top":396.25,"right":638.4140625,"bottom":422.25,"left":488.4140625,"width":150,"height":26,"x":488.4140625,"y":396.25}
106
+ ```
107
+
108
+ これで、要素の真ん中をクリックしたい場合には `driver.click(x: 563, y: 409)` のように実行できます。
109
+
110
+ また、画面の (100, 200) の位置にあるテキストボックスに"admin"というログイン名を入力して、画面の (100, 200) の位置にあるテキストボックスに "Passw0rd!" という文字列を入力して、Submitした結果、ログイン後のダッシュボード画面が表示されていることを確認する場合には、
111
+
112
+ ```
113
+ driver.click(x: 100, y: 200)
114
+ driver.type_text("admin")
115
+ driver.click(x: 100, y: 320)
116
+ driver.type_text("Passw0rd!")
117
+ driver.press_key("Enter")
118
+ driver.sleep_seconds(2)
119
+ driver.capture_screenshot
120
+ ```
121
+
122
+ のような指示だけを出力してください。 `driver.capture_screenshot` を呼ぶと、その後、私が画像をアップロードします。その画像を見て、ログイン画面のままであれば、再度上記のようなログイン手順を、ログインを完了できるように指示だけ出力してください。
123
+
124
+ ```
125
+ driver.click(x: 100, y: 320)
126
+ driver.type_text("Passw0rd!")
127
+ driver.press_key("Enter")
128
+ driver.sleep_seconds(2)
129
+ driver.capture_screenshot
130
+ ```
131
+
132
+ ### 注意点
133
+ * ログイン後のダッシュボード画面に遷移したと判断したら `driver.assertion_ok("ログイン後のダッシュボード画面に遷移すること")` のような指示だけ出力してください。5回やってもうまくいかない場合には `driver.assertion_fail("ログイン後のダッシュボード画面に遷移すること")` のような指示だけ出力してください。
134
+ * 必ず、画像を見てクリックする場所がどこかを判断して `driver.click` を実行するようにしてください。場所がわからない場合には `driver.execute_script` を活用して、要素の場所を確認してください。 `driver.execute_script` を呼ぶと、私がJavaScriptの実行結果をアップロードします。現在のDOMの内容を確認したいときにも `driver.execute_script` は使用できます。例えば `driver.execute_script('document.body.innerHTML')` を実行すると現在のDOMのBodyのHTMLを取得することができます。
135
+ * 何も変化がない場合には、正しい場所をクリックできていない可能性が高いです。その場合には上記のgetBoundingClientRectを使用する手順で、クリックまたはスクロールする位置を必ず確かめてください。
136
+ * 画面外の要素はクリックできないので、getBoundingClientRectの結果、画面外にあることが判明したら、画面内に表示されるようにスクロールしてからクリックしてください。
137
+ * 一覧画面などでは、画面の一部だけがスクロールすることもあります。その場合には、スクロールする要素を特定して、その要素の位置を取得してからスクロール操作を行ってください。
138
+ * `driver.execute_script` を複数実行した場合には、私は最後の結果だけをアップロードしますので、getBoundingClientRectを複数回使用する場合には、1回ずつ分けて指示してください。
139
+ * 最後に実行された内容が `driver.capture_screenshot` または `driver.execute_script` ではない場合には、会話が強制終了してしまいますので、操作を続ける必要がある場合には `driver.execute_script` または `driver.capture_screenshot` を最後に実行してください。
140
+
141
+ #{@additional_instruction ? "### 補足説明\n#{@additional_instruction}" : ""}
142
+
143
+ それでは始めます。テストしたい手順は以下の内容です。
144
+ MARKDOWN
145
+ end
146
+ end
147
+ end