dash-devtools 1.0.0__py3-none-any.whl

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 (53) hide show
  1. dash_devtools/__init__.py +8 -0
  2. dash_devtools/__main__.py +11 -0
  3. dash_devtools/ai_engine.py +441 -0
  4. dash_devtools/browser.py +541 -0
  5. dash_devtools/cli.py +1452 -0
  6. dash_devtools/database.py +338 -0
  7. dash_devtools/dbdiagram.py +183 -0
  8. dash_devtools/e2e.py +329 -0
  9. dash_devtools/fixers/__init__.py +57 -0
  10. dash_devtools/fixers/migration_fixer.py +115 -0
  11. dash_devtools/fixers/ux_fixer.py +106 -0
  12. dash_devtools/fixers/version_bumper.py +115 -0
  13. dash_devtools/gas_mes_test.py +1241 -0
  14. dash_devtools/generators/__init__.py +84 -0
  15. dash_devtools/health.py +476 -0
  16. dash_devtools/hooks/__init__.py +250 -0
  17. dash_devtools/hooks/pre_commit.py +161 -0
  18. dash_devtools/hooks/pre_push.py +275 -0
  19. dash_devtools/init_test.py +352 -0
  20. dash_devtools/markdown_report.py +309 -0
  21. dash_devtools/migrators/__init__.py +21 -0
  22. dash_devtools/perf.py +321 -0
  23. dash_devtools/report.py +667 -0
  24. dash_devtools/reporters/__init__.py +11 -0
  25. dash_devtools/spec.py +230 -0
  26. dash_devtools/stats.py +355 -0
  27. dash_devtools/test_suite.py +690 -0
  28. dash_devtools/testing.py +416 -0
  29. dash_devtools/validators/__init__.py +157 -0
  30. dash_devtools/validators/backend/__init__.py +12 -0
  31. dash_devtools/validators/backend/nodejs.py +245 -0
  32. dash_devtools/validators/backend/python.py +439 -0
  33. dash_devtools/validators/code_quality.py +243 -0
  34. dash_devtools/validators/common/__init__.py +11 -0
  35. dash_devtools/validators/common/quality.py +319 -0
  36. dash_devtools/validators/common/security.py +270 -0
  37. dash_devtools/validators/common/spec.py +273 -0
  38. dash_devtools/validators/detector.py +394 -0
  39. dash_devtools/validators/frontend/__init__.py +14 -0
  40. dash_devtools/validators/frontend/angular.py +245 -0
  41. dash_devtools/validators/frontend/gas.py +310 -0
  42. dash_devtools/validators/frontend/vite.py +539 -0
  43. dash_devtools/validators/migration.py +292 -0
  44. dash_devtools/validators/performance.py +167 -0
  45. dash_devtools/validators/security.py +205 -0
  46. dash_devtools/vision/__init__.py +368 -0
  47. dash_devtools/watch.py +266 -0
  48. dash_devtools/word_report.py +690 -0
  49. dash_devtools-1.0.0.dist-info/METADATA +834 -0
  50. dash_devtools-1.0.0.dist-info/RECORD +53 -0
  51. dash_devtools-1.0.0.dist-info/WHEEL +5 -0
  52. dash_devtools-1.0.0.dist-info/entry_points.txt +2 -0
  53. dash_devtools-1.0.0.dist-info/top_level.txt +1 -0
dash_devtools/e2e.py ADDED
@@ -0,0 +1,329 @@
1
+ """
2
+ E2E 煙霧測試模組
3
+ 使用 agent-browser 檢查頁面是否有 JS 錯誤
4
+ 支援失敗時自動截圖、手機版測試
5
+ """
6
+
7
+ import subprocess
8
+ import json
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional
12
+ from datetime import datetime
13
+
14
+
15
+ def check_agent_browser_installed() -> bool:
16
+ """檢查 agent-browser 是否已安裝"""
17
+ return shutil.which('agent-browser') is not None
18
+
19
+
20
+ def run_agent_browser(*args, timeout: int = 30) -> Dict:
21
+ """執行 agent-browser 指令"""
22
+ cmd = ['agent-browser', *args]
23
+ try:
24
+ result = subprocess.run(
25
+ cmd,
26
+ capture_output=True,
27
+ text=True,
28
+ timeout=timeout
29
+ )
30
+ return {
31
+ 'success': result.returncode == 0,
32
+ 'stdout': result.stdout.strip(),
33
+ 'stderr': result.stderr.strip()
34
+ }
35
+ except subprocess.TimeoutExpired:
36
+ return {'success': False, 'stdout': '', 'stderr': f'超時 ({timeout}秒)'}
37
+ except Exception as e:
38
+ return {'success': False, 'stdout': '', 'stderr': str(e)}
39
+
40
+
41
+ def run_e2e_test(
42
+ url: str,
43
+ timeout: int = 30000,
44
+ check_type: str = "errors",
45
+ screenshot_on_fail: bool = False,
46
+ screenshot_path: Optional[str] = None,
47
+ mobile: bool = False
48
+ ) -> Dict:
49
+ """
50
+ 執行 E2E 煙霧測試
51
+
52
+ Args:
53
+ url: 要測試的網址
54
+ timeout: 超時時間 (毫秒)
55
+ check_type: 檢查類型 (errors, load, all)
56
+ screenshot_on_fail: 失敗時是否截圖
57
+ screenshot_path: 截圖儲存路徑
58
+ mobile: 是否使用手機版視窗 (375x812)
59
+
60
+ Returns:
61
+ 測試結果字典
62
+ """
63
+ result = {
64
+ 'url': url,
65
+ 'success': True,
66
+ 'errors': [],
67
+ 'warnings': [],
68
+ 'loadTime': 0,
69
+ 'status': 200,
70
+ 'screenshot': None,
71
+ 'hasHorizontalScroll': False,
72
+ 'isMobile': mobile,
73
+ 'title': ''
74
+ }
75
+
76
+ # 檢查 agent-browser 是否安裝
77
+ if not check_agent_browser_installed():
78
+ result['success'] = False
79
+ result['errors'].append(
80
+ 'agent-browser 未安裝。請執行: npm install -g agent-browser && agent-browser install'
81
+ )
82
+ return result
83
+
84
+ timeout_sec = timeout // 1000
85
+
86
+ try:
87
+ import time
88
+ start_time = time.time()
89
+
90
+ # 開啟頁面
91
+ open_result = run_agent_browser('open', url, timeout=timeout_sec)
92
+ if not open_result['success']:
93
+ result['success'] = False
94
+ result['errors'].append(f"頁面載入失敗: {open_result['stderr']}")
95
+ result['status'] = 0
96
+ return result
97
+
98
+ # 等待頁面載入完成
99
+ run_agent_browser('wait', '--load', 'networkidle', timeout=timeout_sec)
100
+
101
+ result['loadTime'] = int((time.time() - start_time) * 1000)
102
+
103
+ # 等待額外時間讓 JS 執行
104
+ run_agent_browser('wait', '2000')
105
+
106
+ # 取得頁面標題
107
+ title_result = run_agent_browser('get', 'title')
108
+ if title_result['success']:
109
+ result['title'] = title_result['stdout']
110
+
111
+ # 檢查頁面錯誤
112
+ if check_type in ('errors', 'all'):
113
+ errors_result = run_agent_browser('errors')
114
+ if errors_result['stdout']:
115
+ # 解析錯誤,過濾常見無害錯誤
116
+ for line in errors_result['stdout'].split('\n'):
117
+ line = line.strip()
118
+ if not line:
119
+ continue
120
+ # 忽略常見的非關鍵錯誤
121
+ if any(ignore in line.lower() for ignore in ['favicon', '404', 'analytics']):
122
+ continue
123
+ result['errors'].append(line[:300])
124
+
125
+ # 檢查 Vue/React 常見錯誤
126
+ vue_react_errors = [
127
+ e for e in result['errors']
128
+ if any(err_type in e for err_type in [
129
+ 'TypeError', 'insertBefore', 'Cannot read properties',
130
+ 'is not a function', 'undefined is not an object'
131
+ ])
132
+ ]
133
+ if vue_react_errors:
134
+ result['success'] = False
135
+
136
+ # 手機版:檢查水平滾動
137
+ if mobile:
138
+ # 使用 snapshot 檢查頁面寬度 (透過 console 執行 JS)
139
+ scroll_check = run_agent_browser(
140
+ 'console',
141
+ timeout=5
142
+ )
143
+ # 簡化:如果有錯誤就標記
144
+ if result['errors']:
145
+ result['hasHorizontalScroll'] = True
146
+ result['success'] = False
147
+
148
+ # 判斷最終結果
149
+ if check_type == 'load':
150
+ result['success'] = result['status'] == 200
151
+ elif check_type in ('errors', 'all'):
152
+ result['success'] = len(result['errors']) == 0
153
+
154
+ # 失敗時截圖
155
+ if not result['success'] and screenshot_on_fail:
156
+ if not screenshot_path:
157
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
158
+ device_suffix = "-mobile" if mobile else ""
159
+ screenshot_path = f"/tmp/e2e-screenshot{device_suffix}-{timestamp}.png"
160
+
161
+ screenshot_result = run_agent_browser('screenshot', screenshot_path, '--full')
162
+ if screenshot_result['success']:
163
+ result['screenshot'] = screenshot_path
164
+
165
+ except Exception as e:
166
+ result['success'] = False
167
+ result['errors'].append(str(e))
168
+
169
+ finally:
170
+ # 關閉瀏覽器
171
+ run_agent_browser('close')
172
+
173
+ return result
174
+
175
+
176
+ def run_e2e_tests(
177
+ urls: List[str],
178
+ timeout: int = 30000,
179
+ check_type: str = "errors",
180
+ screenshot_on_fail: bool = False
181
+ ) -> List[Dict]:
182
+ """
183
+ 批次執行 E2E 測試
184
+
185
+ Args:
186
+ urls: 要測試的網址列表
187
+ timeout: 超時時間 (毫秒)
188
+ check_type: 檢查類型
189
+ screenshot_on_fail: 失敗時是否截圖
190
+
191
+ Returns:
192
+ 測試結果列表
193
+ """
194
+ results = []
195
+ for url in urls:
196
+ result = run_e2e_test(url, timeout, check_type, screenshot_on_fail)
197
+ results.append(result)
198
+ return results
199
+
200
+
201
+ def check_puppeteer_installed() -> bool:
202
+ """檢查 agent-browser 是否已安裝 (向下相容舊 API)"""
203
+ return check_agent_browser_installed()
204
+
205
+
206
+ def get_puppeteer_cwd() -> str:
207
+ """向下相容舊 API,現在不需要"""
208
+ return '.'
209
+
210
+
211
+ # ========== 進階功能 ==========
212
+
213
+ def run_e2e_with_login(
214
+ url: str,
215
+ state_file: str,
216
+ timeout: int = 30000,
217
+ check_type: str = "errors",
218
+ screenshot_on_fail: bool = False
219
+ ) -> Dict:
220
+ """
221
+ 使用已儲存的登入狀態執行 E2E 測試
222
+
223
+ Args:
224
+ url: 要測試的網址
225
+ state_file: 登入狀態檔案路徑 (由 agent-browser state save 產生)
226
+ timeout: 超時時間 (毫秒)
227
+ check_type: 檢查類型
228
+ screenshot_on_fail: 失敗時是否截圖
229
+
230
+ Returns:
231
+ 測試結果字典
232
+ """
233
+ result = {
234
+ 'url': url,
235
+ 'success': True,
236
+ 'errors': [],
237
+ 'warnings': [],
238
+ 'loadTime': 0,
239
+ 'status': 200,
240
+ 'screenshot': None,
241
+ 'hasAuth': True
242
+ }
243
+
244
+ if not check_agent_browser_installed():
245
+ result['success'] = False
246
+ result['errors'].append('agent-browser 未安裝')
247
+ return result
248
+
249
+ if not Path(state_file).exists():
250
+ result['success'] = False
251
+ result['errors'].append(f'登入狀態檔案不存在: {state_file}')
252
+ return result
253
+
254
+ timeout_sec = timeout // 1000
255
+
256
+ try:
257
+ import time
258
+
259
+ # 載入登入狀態
260
+ load_result = run_agent_browser('state', 'load', state_file)
261
+ if not load_result['success']:
262
+ result['warnings'].append(f"載入登入狀態失敗: {load_result['stderr']}")
263
+
264
+ start_time = time.time()
265
+
266
+ # 開啟頁面
267
+ open_result = run_agent_browser('open', url, timeout=timeout_sec)
268
+ if not open_result['success']:
269
+ result['success'] = False
270
+ result['errors'].append(f"頁面載入失敗: {open_result['stderr']}")
271
+ return result
272
+
273
+ run_agent_browser('wait', '--load', 'networkidle', timeout=timeout_sec)
274
+ result['loadTime'] = int((time.time() - start_time) * 1000)
275
+
276
+ # 等待 JS 執行
277
+ run_agent_browser('wait', '2000')
278
+
279
+ # 檢查錯誤
280
+ if check_type in ('errors', 'all'):
281
+ errors_result = run_agent_browser('errors')
282
+ if errors_result['stdout']:
283
+ for line in errors_result['stdout'].split('\n'):
284
+ line = line.strip()
285
+ if line and 'favicon' not in line.lower():
286
+ result['errors'].append(line[:300])
287
+
288
+ result['success'] = len(result['errors']) == 0
289
+
290
+ # 失敗時截圖
291
+ if not result['success'] and screenshot_on_fail:
292
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
293
+ screenshot_path = f"/tmp/e2e-auth-{timestamp}.png"
294
+ screenshot_result = run_agent_browser('screenshot', screenshot_path, '--full')
295
+ if screenshot_result['success']:
296
+ result['screenshot'] = screenshot_path
297
+
298
+ except Exception as e:
299
+ result['success'] = False
300
+ result['errors'].append(str(e))
301
+
302
+ finally:
303
+ run_agent_browser('close')
304
+
305
+ return result
306
+
307
+
308
+ def quick_smoke_test(url: str) -> Dict:
309
+ """
310
+ 快速煙霧測試 (簡化版)
311
+
312
+ Args:
313
+ url: 要測試的網址
314
+
315
+ Returns:
316
+ {
317
+ 'ok': bool,
318
+ 'title': str,
319
+ 'load_time_ms': int,
320
+ 'error_count': int
321
+ }
322
+ """
323
+ result = run_e2e_test(url, timeout=15000, check_type='errors')
324
+ return {
325
+ 'ok': result['success'],
326
+ 'title': result.get('title', ''),
327
+ 'load_time_ms': result['loadTime'],
328
+ 'error_count': len(result['errors'])
329
+ }
@@ -0,0 +1,57 @@
1
+ """
2
+ 自動修復模組
3
+
4
+ 修復器:
5
+ - MigrationFixer: HTML 標籤、事件處理器
6
+ - UxFixer: UI/UX 問題(下拉選單、按鈕 title、卡片邊框)
7
+ - VersionBumper: 版本號自動更新
8
+ """
9
+
10
+ from pathlib import Path
11
+ from .migration_fixer import MigrationFixer
12
+ from .ux_fixer import UxFixer
13
+ from .version_bumper import bump_version_if_fixed
14
+
15
+
16
+ def run_auto_fix(projects, fix_types=None, bump_version=True):
17
+ """執行所有專案的自動修復
18
+
19
+ Args:
20
+ projects: 專案路徑列表
21
+ fix_types: 要執行的修復類型 ('migration', 'ux', 'all')
22
+ bump_version: 是否自動更新版本號 (預設 True)
23
+ """
24
+ if fix_types is None:
25
+ fix_types = 'all'
26
+
27
+ results = []
28
+
29
+ for project_path in projects:
30
+ project = Path(project_path)
31
+ project_name = project.name
32
+
33
+ fixes = []
34
+
35
+ # 執行遷移修復
36
+ if fix_types in ('all', 'migration'):
37
+ fixer = MigrationFixer(project)
38
+ migration_fixes = fixer.fix_all()
39
+ fixes.extend(migration_fixes)
40
+
41
+ # 執行 UX 修復
42
+ if fix_types in ('all', 'ux'):
43
+ ux_fixer = UxFixer(project)
44
+ ux_fixes = ux_fixer.fix_all()
45
+ fixes.extend(ux_fixes)
46
+
47
+ # 如果有修復,自動更新版本號
48
+ if fixes and bump_version:
49
+ version_fixes = bump_version_if_fixed(project, fixes)
50
+ fixes.extend(version_fixes)
51
+
52
+ results.append({
53
+ 'project': project_name,
54
+ 'fixes': fixes
55
+ })
56
+
57
+ return results
@@ -0,0 +1,115 @@
1
+ """
2
+ UI 遷移自動修復器
3
+
4
+ 修復項目:
5
+ 1. 不完整的 HTML 標籤 (缺少結束標籤)
6
+ 2. 空白事件處理器
7
+ """
8
+
9
+ import re
10
+ from pathlib import Path
11
+
12
+
13
+ class MigrationFixer:
14
+ """UI 遷移自動修復器"""
15
+
16
+ def __init__(self, project_path):
17
+ self.project_path = Path(project_path)
18
+ self.src_path = self.project_path / 'src'
19
+ self.fixes = []
20
+
21
+ def fix_all(self):
22
+ """執行所有修復"""
23
+ if not self.src_path.exists():
24
+ return self.fixes
25
+
26
+ self.fix_incomplete_html_tags()
27
+ self.fix_empty_event_handlers()
28
+
29
+ return self.fixes
30
+
31
+ def fix_incomplete_html_tags(self):
32
+ """修復不完整的 HTML 標籤"""
33
+ tags_to_fix = ['select', 'textarea', 'table', 'ul', 'ol']
34
+
35
+ for file_path in self.src_path.rglob('*.js'):
36
+ try:
37
+ content = file_path.read_text(encoding='utf-8')
38
+ original_content = content
39
+ rel_path = str(file_path.relative_to(self.project_path))
40
+
41
+ for tag in tags_to_fix:
42
+ # 計算開始和結束標籤數量
43
+ open_pattern = rf'<{tag}[^>]*>'
44
+ close_pattern = rf'</{tag}>'
45
+
46
+ open_matches = list(re.finditer(open_pattern, content))
47
+ close_count = len(re.findall(close_pattern, content))
48
+
49
+ if len(open_matches) > close_count:
50
+ # 需要補上結束標籤
51
+ missing = len(open_matches) - close_count
52
+
53
+ # 找到每個開始標籤,嘗試補上結束標籤
54
+ for match in reversed(open_matches[-missing:]):
55
+ # 找到這個標籤後的位置,在適當位置插入結束標籤
56
+ start_pos = match.end()
57
+
58
+ # 對於 select,找到下一個換行或 > 後插入
59
+ if tag == 'select':
60
+ # 找到選項結束的位置(通常是 </option> 之後的換行)
61
+ remaining = content[start_pos:]
62
+ # 找最後一個 </option> 或 option value
63
+ option_end = remaining.rfind('</option>')
64
+ if option_end == -1:
65
+ # 沒有 option,找下一個 >
66
+ next_line = remaining.find('\n')
67
+ if next_line != -1:
68
+ insert_pos = start_pos + next_line
69
+ content = content[:insert_pos] + f'</{tag}>' + content[insert_pos:]
70
+ else:
71
+ insert_pos = start_pos + option_end + len('</option>')
72
+ content = content[:insert_pos] + f'\n </{tag}>' + content[insert_pos:]
73
+
74
+ elif tag == 'textarea':
75
+ # textarea 通常是自閉合的,在 > 後面加上 </textarea>
76
+ if content[match.end()-2:match.end()] != '/>':
77
+ content = content[:match.end()] + f'</{tag}>' + content[match.end():]
78
+
79
+ if content != original_content:
80
+ file_path.write_text(content, encoding='utf-8')
81
+ self.fixes.append(f'{rel_path}: 修復 HTML 標籤')
82
+
83
+ except Exception as e:
84
+ pass
85
+
86
+ def fix_empty_event_handlers(self):
87
+ """修復空白事件處理器"""
88
+ # 找出 addEventListener('', ...) 並移除或註解
89
+ pattern = r"(\w+)\?*\.addEventListener\s*\(\s*['\"]['\"]"
90
+
91
+ for file_path in self.src_path.rglob('*.js'):
92
+ try:
93
+ content = file_path.read_text(encoding='utf-8')
94
+ original_content = content
95
+ rel_path = str(file_path.relative_to(self.project_path))
96
+
97
+ # 找到並移除空事件處理器
98
+ matches = list(re.finditer(pattern, content))
99
+ if matches:
100
+ for match in reversed(matches):
101
+ # 找到整個 addEventListener 語句
102
+ start = match.start()
103
+ # 找到語句結束(;)
104
+ end = content.find(';', start)
105
+ if end != -1:
106
+ statement = content[start:end+1]
107
+ # 註解掉這行
108
+ content = content[:start] + '// [AUTO-REMOVED] ' + statement + content[end+1:]
109
+
110
+ if content != original_content:
111
+ file_path.write_text(content, encoding='utf-8')
112
+ self.fixes.append(f'{rel_path}: 移除空白事件處理器')
113
+
114
+ except Exception:
115
+ pass
@@ -0,0 +1,106 @@
1
+ """
2
+ UI/UX 自動修復器
3
+
4
+ 修復項目:
5
+ 1. 圖示按鈕加入 title 屬性 (Shoelace sl-icon-button)
6
+ 2. 空白按鈕警告
7
+
8
+ 注意:此修復器適用於 Shoelace UI 框架專案
9
+ """
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+
15
+ class UxFixer:
16
+ """UI/UX 自動修復器 (Shoelace)"""
17
+
18
+ def __init__(self, project_path):
19
+ self.project_path = Path(project_path)
20
+ self.src_path = self.project_path / 'src'
21
+ self.fixes = []
22
+
23
+ def fix_all(self):
24
+ """執行所有修復"""
25
+ if not self.src_path.exists():
26
+ return self.fixes
27
+
28
+ # 只執行 Shoelace 兼容的修復
29
+ self.fix_shoelace_icon_button_titles()
30
+
31
+ return self.fixes
32
+
33
+ def fix_shoelace_icon_button_titles(self):
34
+ """為 Shoelace 圖示按鈕加入 label 屬性(用於無障礙存取)"""
35
+ # Shoelace 圖示對應的中文標籤
36
+ icon_labels = {
37
+ 'pencil': '編輯',
38
+ 'trash': '刪除',
39
+ 'eye': '檢視',
40
+ 'plus': '新增',
41
+ 'x': '關閉',
42
+ 'check': '確認',
43
+ 'arrow-clockwise': '重新整理',
44
+ 'download': '下載',
45
+ 'upload': '上傳',
46
+ 'search': '搜尋',
47
+ 'gear': '設定',
48
+ 'key': '密碼',
49
+ 'shield': '安全',
50
+ 'person': '使用者',
51
+ 'people': '群組',
52
+ 'three-dots': '更多',
53
+ 'three-dots-vertical': '更多',
54
+ 'box-arrow-right': '登出',
55
+ 'box-arrow-in-right': '登入',
56
+ 'save': '儲存',
57
+ 'copy': '複製',
58
+ 'link': '連結',
59
+ 'send': '送出',
60
+ }
61
+
62
+ for file_path in self.src_path.rglob('*.js'):
63
+ try:
64
+ content = file_path.read_text(encoding='utf-8')
65
+ original = content
66
+ rel_path = str(file_path.relative_to(self.project_path))
67
+
68
+ # 找出缺少 label 的 sl-icon-button
69
+ # <sl-icon-button name="pencil"></sl-icon-button>
70
+ pattern = re.compile(
71
+ r'(<sl-icon-button[^>]*name="([^"]+)"[^>]*)' # 捕獲 name 屬性
72
+ r'([^>]*>)', # 其他屬性和結束
73
+ re.DOTALL
74
+ )
75
+
76
+ def add_label(match):
77
+ btn_start = match.group(1)
78
+ icon_name = match.group(2)
79
+ btn_end = match.group(3)
80
+
81
+ # 如果已有 label,不處理
82
+ if 'label=' in btn_start:
83
+ return match.group(0)
84
+
85
+ # 查找對應標籤
86
+ label = icon_labels.get(icon_name)
87
+ if not label:
88
+ # 嘗試從 icon 名稱推測
89
+ for key, val in icon_labels.items():
90
+ if key in icon_name:
91
+ label = val
92
+ break
93
+
94
+ if label:
95
+ return f'{btn_start} label="{label}"{btn_end}'
96
+
97
+ return match.group(0)
98
+
99
+ content = pattern.sub(add_label, content)
100
+
101
+ if content != original:
102
+ file_path.write_text(content, encoding='utf-8')
103
+ self.fixes.append(f'{rel_path}: sl-icon-button 加入 label 屬性')
104
+
105
+ except Exception:
106
+ pass