copy_tuner_client 1.5.0 → 2.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +2 -2
  3. data/.github/workflows/rspec.yml +1 -1
  4. data/CHANGELOG.md +27 -0
  5. data/CLAUDE.md +2 -3
  6. data/README.md +0 -25
  7. data/UPGRADING.md +201 -0
  8. data/app/assets/javascripts/copytuner.js +78 -73
  9. data/app/assets/stylesheets/copytuner.css +1 -1
  10. data/lib/copy_tuner_client/cache.rb +0 -2
  11. data/lib/copy_tuner_client/configuration.rb +23 -37
  12. data/lib/copy_tuner_client/copyray/marker.rb +24 -0
  13. data/lib/copy_tuner_client/copyray/rewriter.rb +113 -0
  14. data/lib/copy_tuner_client/copyray.rb +11 -4
  15. data/lib/copy_tuner_client/copyray_middleware.rb +10 -2
  16. data/lib/copy_tuner_client/helper_extension.rb +0 -3
  17. data/lib/copy_tuner_client/i18n_backend.rb +5 -12
  18. data/lib/copy_tuner_client/version.rb +1 -1
  19. data/skills/copy-tuner/SKILL.md +37 -6
  20. data/skills/copy-tuner-to-locales-cleanup/SKILL.md +4 -4
  21. data/skills/copy-tuner-to-locales-migrate-prefix/references/local-first-regexp.md +4 -9
  22. data/skills/copy-tuner-to-t-migrate/SKILL.md +131 -0
  23. data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +189 -0
  24. data/spec/copy_tuner_client/cache_spec.rb +2 -10
  25. data/spec/copy_tuner_client/client_spec.rb +1 -0
  26. data/spec/copy_tuner_client/configuration_spec.rb +16 -16
  27. data/spec/copy_tuner_client/copyray/marker_spec.rb +41 -0
  28. data/spec/copy_tuner_client/copyray/rewriter_spec.rb +216 -0
  29. data/spec/copy_tuner_client/copyray_middleware_spec.rb +89 -0
  30. data/spec/copy_tuner_client/copyray_spec.rb +22 -39
  31. data/spec/copy_tuner_client/helper_extension_spec.rb +18 -5
  32. data/spec/copy_tuner_client/i18n_backend_spec.rb +8 -15
  33. data/spec/support/client_spec_helpers.rb +0 -1
  34. data/src/copyray.css +11 -0
  35. data/src/copyray.ts +10 -29
  36. data/src/copytuner_bar.ts +15 -1
  37. data/src/main.ts +5 -2
  38. data/src/specimen.ts +17 -7
  39. metadata +9 -1
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ # copy_tuner_client v2.0.0 移行: tt → t 置換スクリプト(SKILL.md 手順 1〜2)。
4
+ #
5
+ # copy_tuner_client が生やしていた独自ヘルパー tt は PR #122 で削除された。gem を上げた
6
+ # アプリのビューに tt(...) が残ると NoMethodError になるため t へ移す。大半は機械的に
7
+ # tt( → t( で済むが、訳文を文字列加工している箇所だけは素朴に t へ変えると開発環境で
8
+ # マーカートークン混入バグが再発する(skills/copy-tuner/SKILL.md の罠を参照)。そこは
9
+ # I18n.t(絶対キー)へ変換すべきなので、このスクリプトは自動変換せず「怪しい」として抽出する。
10
+ #
11
+ # Rails context は不要なので素の ruby で動く:
12
+ #
13
+ # ruby skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb --report
14
+ # ruby skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb --apply-safe
15
+ #
16
+ # 出力する 3 分類:
17
+ # (1) safe : 文字列加工されていない tt 呼び出し。tt( → t( に決定論的変換できる。
18
+ # (2) suspicious : 戻り値が同一行で文字列加工されている tt 呼び出し。I18n.t 提案・要確認。
19
+ # (3) other : app 外 / tt を含む別識別子の誤検出など。自動変換せず目視で確認。
20
+ #
21
+ # 判定はヒューリスティック(完全な AST 解析ではない)。誤判定のコストは
22
+ # safe 誤分類(怪しいのに safe)= マーカー混入バグ再発 → 重大
23
+ # suspicious 誤分類(安全なのに suspicious)= 人間が 1 件確認するだけ → 軽微
24
+ # なので、疑わしきは suspicious に倒す。
25
+
26
+ require 'optparse'
27
+
28
+ # NOTE: 中断を呼び出し側(Bash 手順)へ確実に伝えるため warn + exit(1) を使う。
29
+ def die(message)
30
+ warn message
31
+ exit(1)
32
+ end
33
+
34
+ # ---- 引数 ----
35
+ options = { mode: :report, root: '.' }
36
+ parser =
37
+ OptionParser.new do |opts|
38
+ opts.banner = 'Usage: ruby migrate_tt.rb [--report | --apply-safe] [--root DIR]'
39
+ opts.on('--report', '3 分類を出力する(既定)。ファイルは変更しない') { options[:mode] = :report }
40
+ opts.on('--apply-safe', 'safe 分類のみ tt( → t( を実ファイルに適用する') { options[:mode] = :apply_safe }
41
+ opts.on('--root DIR', 'リポジトリルート(既定: カレントディレクトリ)') { |v| options[:root] = v }
42
+ opts.on('--json', 'report を JSON で出力する(機械処理用)') { options[:json] = true }
43
+ opts.on('-h', '--help', 'ヘルプ') do
44
+ puts opts
45
+ exit(0)
46
+ end
47
+ end
48
+ parser.parse!(ARGV)
49
+
50
+ ROOT = File.expand_path(options[:root])
51
+ die("ルートが見つからない: #{ROOT}") unless File.directory?(ROOT)
52
+
53
+ # 対象: app 配下の .rb / .haml / .erb。それ以外のヒットは other 扱いで提示のみ。
54
+ APP_GLOB = File.join(ROOT, 'app', '**', '*.{rb,haml,erb}')
55
+ # app 外も走査して other に入れる(lib/ 等の取りこぼし提示用)。
56
+ ALL_GLOB = File.join(ROOT, '**', '*.{rb,haml,erb}')
57
+
58
+ # 単語境界の tt 呼び出し。tt( だけでなく haml の `= tt '...'`(括弧なし)も拾う。
59
+ # 直前が識別子文字(attr/http/setting 等の一部や `.tt` のメソッド呼び出し)でないことを (?<![\w.]) で担保。
60
+ TT_CALL = /(?<![\w.])tt(\s*\(|\s+['":@])/
61
+
62
+ # tt の「定義」側(呼び出しではない)。アプリが後方互換で `def tt` を生やしている場合がある。
63
+ # これを safe として t( へ機械置換すると Rails の t を再定義してしまい破滅的なので、定義は別扱いにする。
64
+ TT_DEFINITION = /\b(?:def\s+(?:self\.)?tt\b|alias(?:_method)?\s+:?tt\b|alias(?:_method)?\s+['"]tt['"])/
65
+
66
+ # tt(...) の戻り値が同一行で文字列加工されているかの判定に使う語彙。
67
+ STRING_HELPERS = %w[truncate simple_format strip_tags highlight excerpt word_wrap].freeze
68
+ STRING_METHODS = %w[
69
+ length size bytesize slice first last truncate truncate_words
70
+ gsub sub gsub! sub! strip lstrip rstrip chomp chop chars bytes lines
71
+ scan match match? =~ start_with? end_with? include? index rindex
72
+ upcase downcase capitalize ljust rjust center delete squeeze tr
73
+ to_i to_f to_sym html_safe
74
+ ].freeze
75
+
76
+ # (a) truncate( ... tt( ... ) のように文字列加工ヘルパーの引数になっている
77
+ STRING_HELPER_RE = /\b(?:#{STRING_HELPERS.join('|')})\s*\(\s*[^)]*\btt\b/
78
+ # (b) tt(...) または tt '...' の直後に .method / =~ / [ が続く。引数の閉じ括弧をまたぐ単純近似。
79
+ STRING_METHOD_RE = /\btt\b\s*(?:\([^\n]*?\)|['":@][^\n,]*)\s*(?:\.\s*(?:#{STRING_METHODS.join('|')})\b|=~|\[)/
80
+
81
+ # 先頭ドットの相対キー tt('.foo') は I18n.t では解決できず絶対キー化が要る → 強調表示用。
82
+ RELATIVE_KEY_RE = /\btt\b\s*(?:\(\s*)?['":]\s*\./
83
+
84
+ def relative(path)
85
+ path.sub("#{ROOT}/", '')
86
+ end
87
+
88
+ safe = []
89
+ suspicious = []
90
+ other = []
91
+
92
+ app_set = Dir.glob(APP_GLOB).to_set
93
+
94
+ # 1 ファイル分を走査し、ヒット行を safe / suspicious / other へ振り分ける。
95
+ def scan_file(path, in_app:)
96
+ rel = relative(path)
97
+ File.readlines(path).each_with_index do |line, idx|
98
+ next unless line =~ TT_CALL
99
+
100
+ entry = { file: rel, lineno: idx + 1, code: line.rstrip }
101
+ yield(classify_line(line, entry, in_app: in_app))
102
+ end
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ # ヒット 1 行を分類し、[:safe | :suspicious | :other, entry] を返す。
108
+ def classify_line(line, entry, in_app:)
109
+ # tt の定義(def/alias)は呼び出しではない。t( へ置換すると Rails の t を壊すので絶対に自動変換しない。
110
+ if line.match?(TT_DEFINITION)
111
+ entry[:reason] = 'tt の定義(def/alias)。自動変換禁止。呼び出しを全て t へ移した後に手動で削除する'
112
+ return [:other, entry]
113
+ end
114
+
115
+ unless in_app
116
+ entry[:reason] = 'app 外(要目視)'
117
+ return [:other, entry]
118
+ end
119
+
120
+ return [:safe, entry] unless line.match?(STRING_HELPER_RE) || line.match?(STRING_METHOD_RE)
121
+
122
+ entry[:relative_key] = line.match?(RELATIVE_KEY_RE)
123
+ entry[:hint] = if entry[:relative_key]
124
+ 'I18n.t へ。相対キー(.foo)は絶対キーへ書き換えが必要'
125
+ else
126
+ 'I18n.t(絶対キー) へ置換を検討'
127
+ end
128
+ [:suspicious, entry]
129
+ end
130
+
131
+ buckets = { safe: safe, suspicious: suspicious, other: other }
132
+ Dir.glob(ALL_GLOB).each do |path|
133
+ # vendor / node_modules / tmp 等のノイズを除外
134
+ next if relative(path).start_with?('vendor/', 'node_modules/', 'tmp/', '.git/')
135
+
136
+ scan_file(path, in_app: app_set.include?(path)) do |bucket, entry|
137
+ buckets[bucket] << entry
138
+ end
139
+ end
140
+
141
+ if options[:json]
142
+ require 'json'
143
+ puts JSON.pretty_generate(safe: safe, suspicious: suspicious, other: other)
144
+ exit(0)
145
+ end
146
+
147
+ def print_section(title, entries)
148
+ puts "\n== #{title} (#{entries.size}) =="
149
+ entries.each do |e|
150
+ suffix = if e[:hint]
151
+ " # #{e[:hint]}"
152
+ else
153
+ (e[:reason] ? " # #{e[:reason]}" : '')
154
+ end
155
+ puts "#{e[:file]}:#{e[:lineno]}: #{e[:code].strip}#{suffix}"
156
+ end
157
+ end
158
+
159
+ if options[:mode] == :report
160
+ print_section('safe(tt( → t( に決定論的変換できる)', safe)
161
+ print_section('suspicious(文字列加工あり・I18n.t 提案・要 1 件ずつ確認)', suspicious)
162
+ print_section('other(app 外・誤検出・要目視)', other)
163
+ puts "\n合計: safe=#{safe.size} suspicious=#{suspicious.size} other=#{other.size}"
164
+ exit(0)
165
+ end
166
+
167
+ # ---- --apply-safe: safe 分類のみ tt( → t( を適用 ----
168
+ # 同一ファイル内の safe 行だけを対象に、単語境界の tt を t へ置換する。
169
+ # suspicious / other 行は触らない(行番号で限定)。
170
+ by_file = safe.group_by { |e| e[:file] }
171
+ changed = 0
172
+ by_file.each do |rel, entries|
173
+ path = File.join(ROOT, rel)
174
+ lines = File.readlines(path)
175
+ target_linenos = entries.to_set { |e| e[:lineno] }
176
+ target_linenos.each do |lineno|
177
+ line = lines[lineno - 1]
178
+ # safe 行内の tt 呼び出しのみ t へ。tt('...') や tt(... の tt を t に。
179
+ new_line = line.gsub(TT_CALL) { "t#{Regexp.last_match(1)}" }
180
+ if new_line != line
181
+ lines[lineno - 1] = new_line
182
+ changed += 1
183
+ end
184
+ end
185
+ File.write(path, lines.join)
186
+ end
187
+
188
+ puts "safe 置換を適用: #{changed} 箇所 / #{by_file.size} ファイル"
189
+ puts "残った suspicious=#{suspicious.size} other=#{other.size} は手作業で対応すること(--report で再確認)"
@@ -24,16 +24,6 @@ describe 'CopyTunerClient::Cache' do
24
24
  expect(cache.keys).to match_array(%w[en.test.key en.test.other_key])
25
25
  end
26
26
 
27
- it 'exclude_key_regexpが設定されている場合、該当データを除外すること' do
28
- cache = build_cache(exclude_key_regexp: /^en\.test\.other_key$/)
29
- cache['en.test.key'] = 'expected'
30
- cache['en.test.other_key'] = 'expected'
31
-
32
- cache.download
33
-
34
- expect(cache.queued.keys).to match_array(%w[en.test.key])
35
- end
36
-
37
27
  it 'local_first_key_regexpにマッチするキー(locale除く)はアップロードキューに入れないこと' do
38
28
  cache = build_cache(local_first_key_regexp: /\Aviews\./)
39
29
  cache['ja.views.foo'] = 'local value'
@@ -284,6 +274,7 @@ describe 'CopyTunerClient::Cache' do
284
274
  it 'トップレベルからflushできること' do
285
275
  cache = build_cache
286
276
  CopyTunerClient.configure do |config|
277
+ config.project_id = 1
287
278
  config.cache = cache
288
279
  end
289
280
  expect(cache).to receive(:flush).at_least(:once)
@@ -410,6 +401,7 @@ describe 'CopyTunerClient::Cache' do
410
401
 
411
402
  it 'トップレベル定数から呼び出せること' do
412
403
  CopyTunerClient.configure do |config|
404
+ config.project_id = 1
413
405
  config.cache = cache
414
406
  end
415
407
  expect(cache).to receive(:export)
@@ -207,6 +207,7 @@ describe 'CopyTunerClient' do
207
207
  client = build_client
208
208
  allow(client).to receive(:download)
209
209
  CopyTunerClient.configure do |config|
210
+ config.project_id = 1
210
211
  config.client = client
211
212
  end
212
213
  expect(client).to receive(:deploy)
@@ -253,27 +253,24 @@ describe CopyTunerClient::Configuration do
253
253
  end
254
254
  end
255
255
 
256
- describe '#exclude_key_regexp= (deprecated)' do
257
- let(:config) { CopyTunerClient::Configuration.new }
258
- let(:deprecator) { instance_double(ActiveSupport::Deprecation, warn: nil) }
259
-
260
- before { allow(ActiveSupport::Deprecation).to receive(:new).and_return(deprecator) }
261
-
262
- it 'warns when a value is set' do
263
- expect(deprecator).to receive(:warn).with(/exclude_key_regexp is deprecated/)
264
-
265
- config.exclude_key_regexp = /\Aja\.views\./
256
+ describe 'project_id の必須化' do
257
+ let(:config) do
258
+ config = CopyTunerClient::Configuration.new
259
+ config.api_key = 'abc123'
260
+ config
266
261
  end
267
262
 
268
- it 'stores the assigned value' do
269
- config.exclude_key_regexp = /\Aja\.views\./
270
- expect(config.exclude_key_regexp).to eq(/\Aja\.views\./)
263
+ it 'project_id が未設定のとき apply が ArgumentError を出すこと' do
264
+ expect { config.apply }.to raise_error(ArgumentError, 'project_id is required')
271
265
  end
272
266
 
273
- it 'does not warn when set to nil' do
274
- expect(deprecator).not_to receive(:warn)
267
+ it 'project_id が未設定のとき project_url ArgumentError を出すこと' do
268
+ expect { config.project_url }.to raise_error(ArgumentError, 'project_id is required')
269
+ end
275
270
 
276
- config.exclude_key_regexp = nil
271
+ it 'project_id を設定すると project_url がそれを使った URL を返すこと' do
272
+ config.project_id = 77
273
+ expect(config.project_url).to include('/projects/77')
277
274
  end
278
275
  end
279
276
  end
@@ -294,6 +291,9 @@ shared_context 'stubbed configuration' do
294
291
  allow(CopyTunerClient::Poller).to receive(:new).and_return(poller)
295
292
  allow(CopyTunerClient::ProcessGuard).to receive(:new).and_return(process_guard)
296
293
  subject.logger = logger
294
+ # NOTE: apply は project_id 必須になったため、未設定だと raise する。applied 系テストは
295
+ # project_id 自体を検証しないので適当な値を補っておく
296
+ subject.project_id ||= 1
297
297
  apply
298
298
  end
299
299
  end
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+ require 'copy_tuner_client/copyray/marker'
3
+
4
+ describe CopyTunerClient::Copyray::Marker do
5
+ describe '.encode' do
6
+ it 'キーを可視トークンの区切り記号で囲む' do
7
+ expect(described_class.encode('views.home.index.message')).to eq '⟦CT:views.home.index.message⟧'
8
+ end
9
+
10
+ it 'ドット・アンダースコア・スラッシュを含むキーをそのまま保持する' do
11
+ expect(described_class.encode('en.foo_bar.baz/qux')).to eq '⟦CT:en.foo_bar.baz/qux⟧'
12
+ end
13
+
14
+ it 'マルチバイトキーをそのまま保持する' do
15
+ expect(described_class.encode('ja.見出し')).to eq '⟦CT:ja.見出し⟧'
16
+ end
17
+ end
18
+
19
+ describe '::SCAN_REGEXP' do
20
+ it 'エンコード済みトークンからキーを取り出す' do
21
+ key = 'views.home.index.message'
22
+ match = described_class::SCAN_REGEXP.match(described_class.encode(key))
23
+ expect(match[1]).to eq key
24
+ end
25
+
26
+ it 'ドット・アンダースコア・スラッシュ・マルチバイトを含むキーを取り出す' do
27
+ key = 'ja.foo_bar.baz/qux.見出し'
28
+ match = described_class::SCAN_REGEXP.match(described_class.encode(key))
29
+ expect(match[1]).to eq key
30
+ end
31
+
32
+ it '非貪欲マッチで隣接するトークンを分離する' do
33
+ text = "#{described_class.encode('a.b')}hello#{described_class.encode('c.d')}"
34
+ expect(text.scan(described_class::SCAN_REGEXP).flatten).to eq ['a.b', 'c.d']
35
+ end
36
+
37
+ it 'プレーンテキストにはマッチしない' do
38
+ expect('just a normal sentence with CT: and brackets [x]').not_to match described_class::SCAN_REGEXP
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,216 @@
1
+ require 'spec_helper'
2
+ require 'copy_tuner_client/copyray/marker'
3
+ require 'copy_tuner_client/copyray/rewriter'
4
+
5
+ describe CopyTunerClient::Copyray::Rewriter do
6
+ def marker(key)
7
+ CopyTunerClient::Copyray::Marker.encode(key)
8
+ end
9
+
10
+ def ascii8(str)
11
+ str.dup.force_encoding(Encoding::ASCII_8BIT)
12
+ end
13
+
14
+ describe '.rewrite' do
15
+ # NOTE: rewrite は [html, skipped] を返すが、既存テストの大半は html だけを検証する。
16
+ # 1 回の呼び出しを let でメモ化して共有し、result は html、skipped は 2 要素目を指す。
17
+ let(:rewritten) { described_class.rewrite(html) }
18
+ subject(:result) { rewritten.first }
19
+ let(:skipped) { rewritten.last }
20
+
21
+ context '要素直下の単純なテキストノード' do
22
+ let(:html) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
23
+
24
+ it '親要素に data-copyray-key を付与する' do
25
+ expect(result).to include('data-copyray-key="a.b"')
26
+ end
27
+
28
+ it 'マーカートークンを除去する' do
29
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
30
+ expect(result).to include('>Hello<')
31
+ end
32
+
33
+ it 'skipped が false を返す(属性付与に成功した)' do
34
+ expect(skipped).to be false
35
+ end
36
+ end
37
+
38
+ context 'ActionView が body を html エスケープした平文訳文(トークンは無傷)' do
39
+ # NOTE: 平文訳文は ActionView がエスケープするため body の & < > はエンティティ化するが、
40
+ # トークンの区切り記号 ⟦⟧ は HTML 特殊文字ではないので無傷で残る。Rewriter はこれを拾えること。
41
+ let(:html) { "<html><body><p>#{marker('plain.key')}Hello &amp; &lt;World&gt;</p></body></html>" }
42
+
43
+ it '要素に属性を付与しトークンを除去するが、エスケープ済み body はそのまま残す' do
44
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'plain.key'
45
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
46
+ expect(result).to include('Hello &amp; &lt;World&gt;')
47
+ end
48
+ end
49
+
50
+ context 'ネストしたインライン要素内のマーカー' do
51
+ let(:html) { "<html><body><p>foo <a>#{marker('link.key')}Hi</a> bar</p></body></html>" }
52
+
53
+ it '最も近い親要素(<a>)に属性を付与する' do
54
+ fragment = Nokogiri::HTML(result)
55
+ a = fragment.at_css('a')
56
+ expect(a['data-copyray-key']).to eq 'link.key'
57
+ expect(fragment.at_css('p')['data-copyray-key']).to be_nil
58
+ end
59
+ end
60
+
61
+ context 'テキスト中間に置かれたマーカー' do
62
+ let(:html) { "<html><body><p>foo #{marker('mid.key')}Hi</p></body></html>" }
63
+
64
+ it '内包する要素に属性を付与する' do
65
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'mid.key'
66
+ end
67
+ end
68
+
69
+ context '属性値の中のマーカー' do
70
+ let(:html) { %(<html><body><input placeholder="#{marker('search.placeholder')}検索"></body></html>) }
71
+
72
+ it '要素自身に data-copyray-key を付与する' do
73
+ expect(Nokogiri::HTML(result).at_css('input')['data-copyray-key']).to eq 'search.placeholder'
74
+ end
75
+
76
+ it '属性値からトークンを除去する' do
77
+ placeholder = Nokogiri::HTML(result).at_css('input')['placeholder']
78
+ expect(placeholder).to eq '検索'
79
+ end
80
+ end
81
+
82
+ context 'head(title)の中のマーカー' do
83
+ let(:html) { "<html><head><title>#{marker('page.title')}タイトル</title></head><body></body></html>" }
84
+
85
+ it 'トークンを除去する' do
86
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
87
+ expect(Nokogiri::HTML(result).at_css('title').text).to eq 'タイトル'
88
+ end
89
+ end
90
+
91
+ context '同一テキストノード内の複数マーカー' do
92
+ let(:html) { "<html><body><p>#{marker('first.key')}A#{marker('second.key')}B</p></body></html>" }
93
+
94
+ it 'すべてのキーをカンマ区切りで data-copyray-key に保持する' do
95
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'first.key,second.key'
96
+ end
97
+
98
+ it 'すべてのトークンを除去する' do
99
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
100
+ end
101
+ end
102
+
103
+ context '同一属性値内の複数マーカー' do
104
+ let(:html) { %(<html><body><input placeholder="#{marker('a.key')}x#{marker('b.key')}y"></body></html>) }
105
+
106
+ it 'すべてのキーをカンマ区切りで data-copyray-key に保持する' do
107
+ expect(Nokogiri::HTML(result).at_css('input')['data-copyray-key']).to eq 'a.key,b.key'
108
+ end
109
+
110
+ it '属性値からすべてのトークンを除去する' do
111
+ expect(Nokogiri::HTML(result).at_css('input')['placeholder']).to eq 'xy'
112
+ end
113
+ end
114
+
115
+ context 'マーカーが無い html' do
116
+ let(:html) { '<html><body><p>Nothing to see</p></body></html>' }
117
+
118
+ it 'html を無変形で返す(no-op 高速パス)' do
119
+ expect(result).to eq html
120
+ end
121
+
122
+ it 'skipped が false を返す(変形していない)' do
123
+ expect(skipped).to be false
124
+ end
125
+ end
126
+
127
+ context 'HTML が閾値(MAX_REWRITE_BYTESIZE)を超える場合' do
128
+ # NOTE: マーカーを含みつつ閾値超のサイズにするため、padding でかさ増しする。
129
+ let(:padding) { '<span>x</span>' * ((described_class::MAX_REWRITE_BYTESIZE / 14) + 1) }
130
+ let(:html) { "<html><body><p>#{marker('big.key')}Hello</p>#{padding}</body></html>" }
131
+
132
+ it '閾値を超えるサイズである(前提確認)' do
133
+ expect(html.bytesize).to be > described_class::MAX_REWRITE_BYTESIZE
134
+ end
135
+
136
+ it 'マーカートークンを除去する' do
137
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
138
+ end
139
+
140
+ it 'data-copyray-key を付与しない(Nokogiri を通さない)' do
141
+ expect(result).not_to include(described_class::DATA_ATTR)
142
+ end
143
+
144
+ it 'skipped が true を返す' do
145
+ expect(skipped).to be true
146
+ end
147
+ end
148
+
149
+ context 'ASCII-8BIT に転落したがマーカーを含む body' do
150
+ let(:html) { ascii8("<html><body><p>#{marker('a.b')}日本語</p></body></html>") }
151
+
152
+ it '例外を投げず親要素に属性を付与する' do
153
+ expect { result }.not_to raise_error
154
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'a.b'
155
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
156
+ end
157
+ end
158
+
159
+ context 'マーカーが無い ASCII-8BIT の body' do
160
+ let(:html) { ascii8('<html><body><p>日本語</p></body></html>') }
161
+
162
+ it '例外を投げず無変形で返す' do
163
+ expect { result }.not_to raise_error
164
+ end
165
+
166
+ it '渡された文字列のエンコーディングを破壊しない' do
167
+ result
168
+ expect(html.encoding).to eq Encoding::ASCII_8BIT
169
+ end
170
+ end
171
+
172
+ context 'rewrite が内部で例外を投げる' do
173
+ # NOTE: 壊れた HTML 等で Nokogiri 処理が例外を投げる状況を模す。
174
+ # Copyray は開発支援機能なので、ここでページを 500 にしない(フォールバック動作)。
175
+ let(:html) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
176
+
177
+ before do
178
+ allow(described_class).to receive(:rewrite_with_nokogiri).and_raise(RuntimeError, 'boom')
179
+ end
180
+
181
+ it '例外を伝播させずトークンだけ除去して返す' do
182
+ expect { result }.not_to raise_error
183
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
184
+ expect(result).to include('<p>Hello</p>')
185
+ end
186
+
187
+ it 'skipped が true を返す(属性付与できなかった)' do
188
+ expect(skipped).to be true
189
+ end
190
+
191
+ it 'logger.warn で例外内容を記録する' do
192
+ logger = double('logger')
193
+ allow(CopyTunerClient.configuration).to receive(:logger).and_return(logger)
194
+ expect(logger).to receive(:warn).with(/Rewriter failed.*RuntimeError.*boom/)
195
+ result
196
+ end
197
+
198
+ it 'logger が nil でもフォールバックが落ちない' do
199
+ allow(CopyTunerClient.configuration).to receive(:logger).and_return(nil)
200
+ expect { result }.not_to raise_error
201
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
202
+ end
203
+ end
204
+
205
+ context '出力にマーカートークンが一切残らない' do
206
+ let(:html) do
207
+ "<html><head><title>#{marker('t')}T</title></head>" \
208
+ "<body><input placeholder=\"#{marker('p')}x\"><p>#{marker('a')}A<a>#{marker('b')}B</a></p></body></html>"
209
+ end
210
+
211
+ it 'シリアライズ後の出力にトークンが残っていない' do
212
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,89 @@
1
+ require 'spec_helper'
2
+ require 'copy_tuner_client/copyray_middleware'
3
+ require 'copy_tuner_client/copyray/marker'
4
+ require 'copy_tuner_client/translation_log'
5
+
6
+ describe CopyTunerClient::CopyrayMiddleware do
7
+ def marker(key)
8
+ CopyTunerClient::Copyray::Marker.encode(key)
9
+ end
10
+
11
+ let(:headers) { { 'Content-Type' => 'text/html' } }
12
+ let(:app) { ->(_env) { [status, headers, [body]] } }
13
+ let(:status) { 200 }
14
+
15
+ subject(:middleware) { described_class.new(app) }
16
+
17
+ before do
18
+ CopyTunerClient.configure do |configuration|
19
+ configuration.project_id = 1
20
+ configuration.client = FakeClient.new
21
+ end
22
+ # NOTE: CSS/JS 挿入は Rails の ActionController::Base.helpers に依存するため、
23
+ # Rewriter の効果だけを検証できるよう no-op にスタブする。
24
+ allow(middleware).to receive(:append_css) { |html, _| html }
25
+ allow(middleware).to receive(:append_js) { |html, *| html }
26
+ end
27
+
28
+ context 'マーカートークンを含む HTML レスポンスのとき' do
29
+ let(:body) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
30
+
31
+ it 'マーカーを data-copyray-key 属性に書き換え、トークンを除去する' do
32
+ _status, _headers, response = middleware.call({})
33
+ result = response.join
34
+
35
+ expect(result).to include('data-copyray-key="a.b"')
36
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
37
+ end
38
+
39
+ it '書き換え後のボディから Content-Length を再計算する' do
40
+ _status, out_headers, response = middleware.call({})
41
+ expect(out_headers['Content-Length']).to eq response.join.bytesize.to_s
42
+ end
43
+ end
44
+
45
+ context 'HTML 以外のレスポンスのとき' do
46
+ let(:headers) { { 'Content-Type' => 'application/json' } }
47
+ let(:body) { "{\"x\":\"#{marker('a.b')}\"}" }
48
+
49
+ it '書き換えずにそのまま通過させる' do
50
+ _status, _headers, response = middleware.call({})
51
+ expect(response.join).to eq body
52
+ end
53
+ end
54
+
55
+ describe '#append_js' do
56
+ # NOTE: append_js は private かつ Rails の view ヘルパー(javascript_tag 等)に依存する。
57
+ # トップレベルの no-op スタブを外して実体を呼び、ヘルパーは渡された script 本文をそのまま
58
+ # 返す最小フェイクに差し替えて、window.CopyTuner に keysSkipped が埋まることだけ検証する。
59
+ subject(:script) { middleware.__send__(:append_js, '<html><body></body></html>', nil, skipped: skipped) }
60
+
61
+ let(:fake_helpers) do
62
+ Class.new {
63
+ def javascript_tag(content, **_opts) = content
64
+ def javascript_include_tag(*, **) = ''
65
+ }.new
66
+ end
67
+
68
+ before do
69
+ allow(middleware).to receive(:append_js).and_call_original
70
+ allow(middleware).to receive(:helpers).and_return(fake_helpers)
71
+ end
72
+
73
+ context 'skipped が true のとき' do
74
+ let(:skipped) { true }
75
+
76
+ it 'window.CopyTuner に keysSkipped: true を出力する' do
77
+ expect(script).to include('keysSkipped: true')
78
+ end
79
+ end
80
+
81
+ context 'skipped が false のとき' do
82
+ let(:skipped) { false }
83
+
84
+ it 'window.CopyTuner に keysSkipped: false を出力する' do
85
+ expect(script).to include('keysSkipped: false')
86
+ end
87
+ end
88
+ end
89
+ end