@yeyuan98/opencode-bioresearcher-plugin 1.5.0 → 1.5.1

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 (133) hide show
  1. package/README.md +50 -50
  2. package/dist/index.js +6 -6
  3. package/dist/skills/bioresearcher-tests/README.md +90 -0
  4. package/dist/skills/bioresearcher-tests/SKILL.md +255 -0
  5. package/dist/skills/bioresearcher-tests/pyproject.toml +6 -0
  6. package/dist/skills/bioresearcher-tests/resources/json_samples/in_markdown.md.gz +0 -0
  7. package/dist/skills/bioresearcher-tests/resources/json_samples/nested_object.json.gz +0 -0
  8. package/dist/skills/bioresearcher-tests/resources/json_samples/schema_draft7.json.gz +0 -0
  9. package/dist/skills/bioresearcher-tests/resources/json_samples/simple_array.json.gz +0 -0
  10. package/dist/skills/bioresearcher-tests/resources/json_samples/simple_object.json.gz +0 -0
  11. package/dist/skills/bioresearcher-tests/resources/obo_sample.obo.gz +0 -0
  12. package/dist/skills/bioresearcher-tests/resources/pubmed_sample.xml.gz +0 -0
  13. package/dist/skills/bioresearcher-tests/resources/table_sample.xlsx.gz +0 -0
  14. package/dist/skills/bioresearcher-tests/test_cases/json_tests.md +137 -0
  15. package/dist/skills/bioresearcher-tests/test_cases/misc_tests.md +141 -0
  16. package/dist/skills/bioresearcher-tests/test_cases/parser_tests.md +80 -0
  17. package/dist/skills/bioresearcher-tests/test_cases/skill_tests.md +59 -0
  18. package/dist/skills/bioresearcher-tests/test_cases/table_tests.md +194 -0
  19. package/dist/skills/bioresearcher-tests/test_runner.py +607 -0
  20. package/dist/skills/env-jsonc-setup/SKILL.md +206 -206
  21. package/dist/skills/long-table-summary/SKILL.md +224 -153
  22. package/dist/skills/long-table-summary/combine_outputs.py +55 -9
  23. package/dist/skills/long-table-summary/generate_prompts.py +9 -0
  24. package/dist/skills/pubmed-weekly/pubmed_weekly.py +130 -29
  25. package/dist/{db-tools → tools/db}/backends/mysql/translator.js +23 -23
  26. package/dist/{db-tools → tools/db}/tools.js +34 -34
  27. package/dist/{misc-tools → tools/misc}/json-validate.js +4 -5
  28. package/dist/{skill-tools → tools/skill}/registry.js +1 -1
  29. package/package.json +1 -1
  30. package/dist/db-tools/executor.d.ts +0 -13
  31. package/dist/db-tools/executor.js +0 -54
  32. package/dist/db-tools/pool.d.ts +0 -8
  33. package/dist/db-tools/pool.js +0 -49
  34. package/dist/db-tools/tools/index.d.ts +0 -27
  35. package/dist/db-tools/tools/index.js +0 -191
  36. package/dist/db-tools/types.d.ts +0 -94
  37. package/dist/db-tools/types.js +0 -40
  38. package/dist/misc-tools/json-tools.d.ts +0 -33
  39. package/dist/misc-tools/json-tools.js +0 -187
  40. package/dist/skill/frontmatter.d.ts +0 -2
  41. package/dist/skill/frontmatter.js +0 -65
  42. package/dist/skill/index.d.ts +0 -3
  43. package/dist/skill/index.js +0 -2
  44. package/dist/skill/registry.d.ts +0 -11
  45. package/dist/skill/registry.js +0 -64
  46. package/dist/skill/tool.d.ts +0 -9
  47. package/dist/skill/tool.js +0 -115
  48. package/dist/skill/types.d.ts +0 -22
  49. package/dist/skill/types.js +0 -7
  50. /package/dist/{db-tools → tools/db}/backends/index.d.ts +0 -0
  51. /package/dist/{db-tools → tools/db}/backends/index.js +0 -0
  52. /package/dist/{db-tools → tools/db}/backends/mongodb/backend.d.ts +0 -0
  53. /package/dist/{db-tools → tools/db}/backends/mongodb/backend.js +0 -0
  54. /package/dist/{db-tools → tools/db}/backends/mongodb/connection.d.ts +0 -0
  55. /package/dist/{db-tools → tools/db}/backends/mongodb/connection.js +0 -0
  56. /package/dist/{db-tools → tools/db}/backends/mongodb/index.d.ts +0 -0
  57. /package/dist/{db-tools → tools/db}/backends/mongodb/index.js +0 -0
  58. /package/dist/{db-tools → tools/db}/backends/mongodb/translator.d.ts +0 -0
  59. /package/dist/{db-tools → tools/db}/backends/mongodb/translator.js +0 -0
  60. /package/dist/{db-tools → tools/db}/backends/mysql/backend.d.ts +0 -0
  61. /package/dist/{db-tools → tools/db}/backends/mysql/backend.js +0 -0
  62. /package/dist/{db-tools → tools/db}/backends/mysql/connection.d.ts +0 -0
  63. /package/dist/{db-tools → tools/db}/backends/mysql/connection.js +0 -0
  64. /package/dist/{db-tools → tools/db}/backends/mysql/index.d.ts +0 -0
  65. /package/dist/{db-tools → tools/db}/backends/mysql/index.js +0 -0
  66. /package/dist/{db-tools → tools/db}/backends/mysql/translator.d.ts +0 -0
  67. /package/dist/{db-tools → tools/db}/core/base.d.ts +0 -0
  68. /package/dist/{db-tools → tools/db}/core/base.js +0 -0
  69. /package/dist/{db-tools → tools/db}/core/config-loader.d.ts +0 -0
  70. /package/dist/{db-tools → tools/db}/core/config-loader.js +0 -0
  71. /package/dist/{db-tools → tools/db}/core/index.d.ts +0 -0
  72. /package/dist/{db-tools → tools/db}/core/index.js +0 -0
  73. /package/dist/{db-tools → tools/db}/core/jsonc-parser.d.ts +0 -0
  74. /package/dist/{db-tools → tools/db}/core/jsonc-parser.js +0 -0
  75. /package/dist/{db-tools → tools/db}/core/validator.d.ts +0 -0
  76. /package/dist/{db-tools → tools/db}/core/validator.js +0 -0
  77. /package/dist/{db-tools → tools/db}/index.d.ts +0 -0
  78. /package/dist/{db-tools → tools/db}/index.js +0 -0
  79. /package/dist/{db-tools → tools/db}/interface/backend.d.ts +0 -0
  80. /package/dist/{db-tools → tools/db}/interface/backend.js +0 -0
  81. /package/dist/{db-tools → tools/db}/interface/connection.d.ts +0 -0
  82. /package/dist/{db-tools → tools/db}/interface/connection.js +0 -0
  83. /package/dist/{db-tools → tools/db}/interface/index.d.ts +0 -0
  84. /package/dist/{db-tools → tools/db}/interface/index.js +0 -0
  85. /package/dist/{db-tools → tools/db}/interface/query.d.ts +0 -0
  86. /package/dist/{db-tools → tools/db}/interface/query.js +0 -0
  87. /package/dist/{db-tools → tools/db}/interface/schema.d.ts +0 -0
  88. /package/dist/{db-tools → tools/db}/interface/schema.js +0 -0
  89. /package/dist/{db-tools → tools/db}/tools.d.ts +0 -0
  90. /package/dist/{db-tools → tools/db}/utils.d.ts +0 -0
  91. /package/dist/{db-tools → tools/db}/utils.js +0 -0
  92. /package/dist/{misc-tools → tools/misc}/calculator.d.ts +0 -0
  93. /package/dist/{misc-tools → tools/misc}/calculator.js +0 -0
  94. /package/dist/{misc-tools → tools/misc}/index.d.ts +0 -0
  95. /package/dist/{misc-tools → tools/misc}/index.js +0 -0
  96. /package/dist/{misc-tools → tools/misc}/json-extract.d.ts +0 -0
  97. /package/dist/{misc-tools → tools/misc}/json-extract.js +0 -0
  98. /package/dist/{misc-tools → tools/misc}/json-infer.d.ts +0 -0
  99. /package/dist/{misc-tools → tools/misc}/json-infer.js +0 -0
  100. /package/dist/{misc-tools → tools/misc}/json-validate.d.ts +0 -0
  101. /package/dist/{misc-tools → tools/misc}/timer.d.ts +0 -0
  102. /package/dist/{misc-tools → tools/misc}/timer.js +0 -0
  103. /package/dist/{parser-tools → tools/parser}/obo/index.d.ts +0 -0
  104. /package/dist/{parser-tools → tools/parser}/obo/index.js +0 -0
  105. /package/dist/{parser-tools → tools/parser}/obo/obo.d.ts +0 -0
  106. /package/dist/{parser-tools → tools/parser}/obo/obo.js +0 -0
  107. /package/dist/{parser-tools → tools/parser}/obo/types.d.ts +0 -0
  108. /package/dist/{parser-tools → tools/parser}/obo/types.js +0 -0
  109. /package/dist/{parser-tools → tools/parser}/obo/utils.d.ts +0 -0
  110. /package/dist/{parser-tools → tools/parser}/obo/utils.js +0 -0
  111. /package/dist/{parser-tools → tools/parser}/pubmed/index.d.ts +0 -0
  112. /package/dist/{parser-tools → tools/parser}/pubmed/index.js +0 -0
  113. /package/dist/{parser-tools → tools/parser}/pubmed/pubmed.d.ts +0 -0
  114. /package/dist/{parser-tools → tools/parser}/pubmed/pubmed.js +0 -0
  115. /package/dist/{parser-tools → tools/parser}/pubmed/types.d.ts +0 -0
  116. /package/dist/{parser-tools → tools/parser}/pubmed/types.js +0 -0
  117. /package/dist/{parser-tools → tools/parser}/pubmed/utils.d.ts +0 -0
  118. /package/dist/{parser-tools → tools/parser}/pubmed/utils.js +0 -0
  119. /package/dist/{skill-tools → tools/skill}/frontmatter.d.ts +0 -0
  120. /package/dist/{skill-tools → tools/skill}/frontmatter.js +0 -0
  121. /package/dist/{skill-tools → tools/skill}/index.d.ts +0 -0
  122. /package/dist/{skill-tools → tools/skill}/index.js +0 -0
  123. /package/dist/{skill-tools → tools/skill}/registry.d.ts +0 -0
  124. /package/dist/{skill-tools → tools/skill}/tool.d.ts +0 -0
  125. /package/dist/{skill-tools → tools/skill}/tool.js +0 -0
  126. /package/dist/{skill-tools → tools/skill}/types.d.ts +0 -0
  127. /package/dist/{skill-tools → tools/skill}/types.js +0 -0
  128. /package/dist/{table-tools → tools/table}/index.d.ts +0 -0
  129. /package/dist/{table-tools → tools/table}/index.js +0 -0
  130. /package/dist/{table-tools → tools/table}/tools.d.ts +0 -0
  131. /package/dist/{table-tools → tools/table}/tools.js +0 -0
  132. /package/dist/{table-tools → tools/table}/utils.d.ts +0 -0
  133. /package/dist/{table-tools → tools/table}/utils.js +0 -0
@@ -0,0 +1,607 @@
1
+ """
2
+ BioResearcher Plugin Test Runner
3
+
4
+ Generates test structure and provides utilities for running tests.
5
+ Actual tests are executed by the agent using the skill.
6
+
7
+ Usage:
8
+ uv run python test_runner.py --help
9
+ uv run python test_runner.py --list
10
+ uv run python test_runner.py --category parser
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ @dataclass
25
+ class TestCase:
26
+ """Represents a single test case."""
27
+
28
+ name: str
29
+ tool: str
30
+ input: dict
31
+ validators: list[str]
32
+ expected: str
33
+ category: str
34
+
35
+
36
+ @dataclass
37
+ class TestResult:
38
+ """Result of a single test execution."""
39
+
40
+ test_name: str
41
+ tool: str
42
+ category: str
43
+ passed: bool
44
+ actual: Any
45
+ expected: str
46
+ validators: list[str]
47
+ validator_results: list[dict] = field(default_factory=list)
48
+ error: str | None = None
49
+
50
+
51
+ def parse_test_cases(md_content: str, category: str) -> list[TestCase]:
52
+ """
53
+ Parse test cases from markdown content.
54
+
55
+ Format:
56
+ ## Test: <name>
57
+ - Tool: <tool>
58
+ - Input:
59
+ ```json
60
+ {...}
61
+ ```
62
+ - Validators:
63
+ - <validator>
64
+ - Expected: <description>
65
+ """
66
+ tests = []
67
+
68
+ test_blocks = re.split(r"\n## Test: ", md_content)
69
+
70
+ for block in test_blocks[1:]:
71
+ if not block.strip():
72
+ continue
73
+
74
+ try:
75
+ name_match = re.match(r"(.+?)\n", block)
76
+ if not name_match:
77
+ continue
78
+ name = name_match.group(1).strip()
79
+
80
+ tool_match = re.search(r"- Tool:\s*(.+?)\n", block)
81
+ if not tool_match:
82
+ continue
83
+ tool = tool_match.group(1).strip()
84
+
85
+ input_match = re.search(r"- Input:\s*```json\n(.+?)```", block, re.DOTALL)
86
+ if not input_match:
87
+ continue
88
+ input_json = json.loads(input_match.group(1).strip())
89
+
90
+ validators_match = re.search(r"- Validators:\n((?: - .+\n)+)", block)
91
+ if validators_match:
92
+ validators = [
93
+ v.strip()[2:]
94
+ for v in validators_match.group(1).strip().split("\n")
95
+ if v.strip().startswith("- ")
96
+ ]
97
+ else:
98
+ validators = []
99
+
100
+ expected_match = re.search(r"- Expected:\s*(.+?)(?:\n|$)", block)
101
+ expected = expected_match.group(1).strip() if expected_match else ""
102
+
103
+ tests.append(
104
+ TestCase(
105
+ name=name,
106
+ tool=tool,
107
+ input=input_json,
108
+ validators=validators,
109
+ expected=expected,
110
+ category=category,
111
+ )
112
+ )
113
+ except (json.JSONDecodeError, AttributeError) as e:
114
+ continue
115
+
116
+ return tests
117
+
118
+
119
+ def load_all_tests(test_cases_dir: str) -> dict[str, list[TestCase]]:
120
+ """Load all test cases from test_cases directory."""
121
+ categories = {
122
+ "parser_tests.md": "parser",
123
+ "table_tests.md": "table",
124
+ "json_tests.md": "json",
125
+ "misc_tests.md": "misc",
126
+ "skill_tests.md": "skill",
127
+ }
128
+
129
+ all_tests = {}
130
+
131
+ for filename, category in categories.items():
132
+ filepath = os.path.join(test_cases_dir, filename)
133
+ if os.path.exists(filepath):
134
+ with open(filepath, "r", encoding="utf-8") as f:
135
+ content = f.read()
136
+ tests = parse_test_cases(content, category)
137
+ all_tests[category] = tests
138
+
139
+ return all_tests
140
+
141
+
142
+ def validate_result(result: Any, validators: list[str]) -> tuple[bool, list[dict]]:
143
+ """
144
+ Run validators against result.
145
+
146
+ Returns:
147
+ (all_passed, validator_results)
148
+ """
149
+ results = []
150
+ all_passed = True
151
+
152
+ result_str = json.dumps(result) if isinstance(result, (dict, list)) else str(result)
153
+ result_obj = result if isinstance(result, dict) else {}
154
+
155
+ for validator in validators:
156
+ passed = False
157
+ detail = ""
158
+
159
+ if validator == "success_is_true":
160
+ passed = result_obj.get("success") is True
161
+ detail = f"success={result_obj.get('success')}"
162
+
163
+ elif validator == "success_is_false":
164
+ passed = result_obj.get("success") is False
165
+ detail = f"success={result_obj.get('success')}"
166
+
167
+ elif validator == "file_exists":
168
+ path = result_obj.get("filePath") or result_obj.get("file_path")
169
+ passed = path and os.path.exists(path)
170
+ detail = f"path={path}, exists={passed}"
171
+
172
+ elif validator == "json_valid":
173
+ try:
174
+ if isinstance(result, str):
175
+ json.loads(result)
176
+ passed = True
177
+ except:
178
+ pass
179
+ detail = "parsed as JSON"
180
+
181
+ elif validator.startswith("stats.total"):
182
+ match = re.match(r"stats\.total\s*===\s*(\d+)", validator)
183
+ if match:
184
+ expected = int(match.group(1))
185
+ actual = result_obj.get("stats", {}).get("total", 0)
186
+ passed = actual == expected
187
+ detail = f"total={actual}, expected={expected}"
188
+
189
+ elif validator.startswith("stats.successful"):
190
+ match = re.match(r"stats\.successful\s*===\s*(\d+)", validator)
191
+ if match:
192
+ expected = int(match.group(1))
193
+ actual = result_obj.get("stats", {}).get("successful", 0)
194
+ passed = actual == expected
195
+ detail = f"successful={actual}, expected={expected}"
196
+
197
+ elif validator.startswith("stats.terms"):
198
+ match = re.match(r"stats\.terms\s*>=\s*(\d+)", validator)
199
+ if match:
200
+ min_val = int(match.group(1))
201
+ actual = result_obj.get("stats", {}).get("terms", 0)
202
+ passed = actual >= min_val
203
+ detail = f"terms={actual}, min={min_val}"
204
+
205
+ elif validator.startswith("array_length"):
206
+ match = re.match(r"array_length\s*>=?\s*(\d+)", validator)
207
+ if match:
208
+ min_len = int(match.group(1))
209
+ arr = (
210
+ result_obj.get("sheets")
211
+ or result_obj.get("results")
212
+ or result_obj.get("matched_rows")
213
+ or result_obj.get("preview")
214
+ or result_obj.get("headers")
215
+ or []
216
+ )
217
+ passed = len(arr) >= min_len
218
+ detail = f"length={len(arr)}, min={min_len}"
219
+
220
+ elif validator.startswith("rows"):
221
+ match = re.match(r"rows\s*===\s*(\d+)", validator)
222
+ if match:
223
+ expected = int(match.group(1))
224
+ actual = result_obj.get("rows", 0)
225
+ passed = actual == expected
226
+ detail = f"rows={actual}, expected={expected}"
227
+
228
+ elif validator.startswith("columns"):
229
+ match = re.match(r"columns\s*===\s*(\d+)", validator)
230
+ if match:
231
+ expected = int(match.group(1))
232
+ actual = result_obj.get("columns", 0)
233
+ passed = actual == expected
234
+ detail = f"columns={actual}, expected={expected}"
235
+
236
+ elif validator.startswith("contains_string"):
237
+ match = re.search(r'"(.+?)"', validator)
238
+ if match:
239
+ substr = match.group(1)
240
+ passed = substr.lower() in result_str.lower()
241
+ detail = f"contains '{substr}': {passed}"
242
+
243
+ elif validator.startswith("error.code"):
244
+ match = re.match(r'error\.code\s*===\s*"(.+?)"', validator)
245
+ if match:
246
+ expected = match.group(1)
247
+ actual = result_obj.get("error", {}).get("code")
248
+ passed = actual == expected
249
+ detail = f"error.code={actual}, expected={expected}"
250
+
251
+ elif validator.startswith("result"):
252
+ match = re.match(r"result\s*===\s*(\d+\.?\d*)", validator)
253
+ if match:
254
+ expected = float(match.group(1))
255
+ actual = result_obj.get("result")
256
+ passed = actual is not None and abs(float(actual) - expected) < 0.001
257
+ detail = f"result={actual}, expected={expected}"
258
+
259
+ elif validator == "has result":
260
+ passed = "result" in result_obj
261
+ detail = f"has result: {passed}"
262
+
263
+ elif validator == "has data object":
264
+ passed = "data" in result_obj and isinstance(result_obj["data"], dict)
265
+ detail = f"has data: {passed}"
266
+
267
+ elif validator.startswith("data.name"):
268
+ match = re.match(r'data\.(\w+)\s*===\s*"(.+)"', validator)
269
+ if match:
270
+ key, expected = match.group(1), match.group(2)
271
+ actual = result_obj.get("data", {}).get(key)
272
+ passed = actual == expected
273
+ detail = f"data.{key}={actual}, expected={expected}"
274
+
275
+ elif validator.startswith("data.value"):
276
+ match = re.match(r"data\.(\w+)\s*===\s*(\d+)", validator)
277
+ if match:
278
+ key, expected = match.group(1), int(match.group(2))
279
+ actual = result_obj.get("data", {}).get(key)
280
+ passed = actual == expected
281
+ detail = f"data.{key}={actual}, expected={expected}"
282
+
283
+ elif validator.startswith("data.embedded"):
284
+ match = re.match(r'data\.(\w+)\s*===\s*"(.+)"', validator)
285
+ if match:
286
+ key, expected = match.group(1), match.group(2)
287
+ actual = str(result_obj.get("data", {}).get(key, ""))
288
+ passed = actual == expected
289
+ detail = f"data.{key}={actual}, expected={expected}"
290
+
291
+ elif validator == "isArray(data)":
292
+ passed = isinstance(result_obj.get("data"), list)
293
+ detail = f"isArray: {passed}"
294
+
295
+ elif validator == "has data.nested":
296
+ data = result_obj.get("data", {})
297
+ passed = "nested" in data if isinstance(data, dict) else False
298
+ detail = f"has nested: {passed}"
299
+
300
+ elif validator == "has count":
301
+ passed = "count" in result_obj or "count" in result_obj.get("metadata", {})
302
+ detail = f"has count: {passed}"
303
+
304
+ elif validator.startswith("inferredType"):
305
+ match = re.match(r'inferredType\s*===\s*"(.+?)"', validator)
306
+ if match:
307
+ expected = match.group(1)
308
+ actual = result_obj.get("metadata", {}).get("inferredType")
309
+ passed = actual == expected
310
+ detail = f"inferredType={actual}, expected={expected}"
311
+
312
+ elif validator.startswith("strictMode"):
313
+ match = re.match(r'strictMode\s*===\s*"?(true|false)"?', validator)
314
+ if match:
315
+ expected = match.group(1) == "true"
316
+ actual = result_obj.get("metadata", {}).get("strictMode")
317
+ passed = actual == expected
318
+ detail = f"strictMode={actual}, expected={expected}"
319
+
320
+ elif validator.startswith("method"):
321
+ match = re.match(r'method\s*===\s*"(.+?)"', validator)
322
+ if match:
323
+ expected = match.group(1)
324
+ actual = result_obj.get("metadata", {}).get("method")
325
+ passed = actual == expected
326
+ detail = f"method={actual}, expected={expected}"
327
+
328
+ elif validator == "has errors":
329
+ passed = "errors" in result_obj and len(result_obj.get("errors", [])) > 0
330
+ detail = f"has errors: {passed}"
331
+
332
+ elif validator == "valid === true OR has data":
333
+ passed = result_obj.get("valid") is True or "data" in result_obj
334
+ detail = f"valid={result_obj.get('valid')}, has_data={'data' in result_obj}"
335
+
336
+ elif validator.startswith("headers includes"):
337
+ match = re.search(r'"(.+?)"', validator)
338
+ if match:
339
+ expected = match.group(1)
340
+ headers = result_obj.get("headers", [])
341
+ passed = expected in headers or expected.lower() in [
342
+ h.lower() for h in headers
343
+ ]
344
+ detail = f"headers={headers}"
345
+
346
+ elif validator == "has preview array":
347
+ passed = "preview" in result_obj and isinstance(result_obj["preview"], list)
348
+ detail = f"has preview: {passed}"
349
+
350
+ elif validator == "has total_rows":
351
+ passed = "total_rows" in result_obj
352
+ detail = f"has total_rows: {passed}"
353
+
354
+ elif validator == "has headers array":
355
+ passed = "headers" in result_obj and isinstance(result_obj["headers"], list)
356
+ detail = f"has headers: {passed}"
357
+
358
+ elif validator == "has sheets array":
359
+ passed = "sheets" in result_obj and isinstance(result_obj["sheets"], list)
360
+ detail = f"has sheets: {passed}"
361
+
362
+ elif validator == "has value":
363
+ passed = "value" in result_obj
364
+ detail = f"has value: {passed}"
365
+
366
+ elif validator == "has type":
367
+ passed = "type" in result_obj
368
+ detail = f"has type: {passed}"
369
+
370
+ elif validator == "has data array":
371
+ passed = "data" in result_obj and isinstance(result_obj["data"], list)
372
+ detail = f"has data array: {passed}"
373
+
374
+ elif validator == "has matched_rows":
375
+ passed = "matched_rows" in result_obj
376
+ detail = f"has matched_rows: {passed}"
377
+
378
+ elif validator == "has total_matches":
379
+ passed = "total_matches" in result_obj
380
+ detail = f"has total_matches: {passed}"
381
+
382
+ elif validator == "has results array":
383
+ passed = "results" in result_obj and isinstance(result_obj["results"], list)
384
+ detail = f"has results: {passed}"
385
+
386
+ elif validator == "has total_found":
387
+ passed = "total_found" in result_obj
388
+ detail = f"has total_found: {passed}"
389
+
390
+ elif validator == "has summaries object":
391
+ passed = "summaries" in result_obj and isinstance(
392
+ result_obj["summaries"], dict
393
+ )
394
+ detail = f"has summaries: {passed}"
395
+
396
+ elif validator == "has groups object":
397
+ passed = "groups" in result_obj and isinstance(result_obj["groups"], dict)
398
+ detail = f"has groups: {passed}"
399
+
400
+ elif validator == "has pivot object":
401
+ passed = "pivot" in result_obj and isinstance(result_obj["pivot"], dict)
402
+ detail = f"has pivot: {passed}"
403
+
404
+ elif validator == "has columns array":
405
+ passed = "columns" in result_obj and isinstance(result_obj["columns"], list)
406
+ detail = f"has columns: {passed}"
407
+
408
+ elif validator == "rows_appended === 1":
409
+ passed = result_obj.get("rows_appended") == 1
410
+ detail = f"rows_appended={result_obj.get('rows_appended')}"
411
+
412
+ elif validator == "rows_created === 2":
413
+ passed = result_obj.get("rows_created") == 2
414
+ detail = f"rows_created={result_obj.get('rows_created')}"
415
+
416
+ elif validator == "directory exists":
417
+ path = result_obj.get("filePath") or result_obj.get("file_path")
418
+ passed = path and os.path.isdir(path)
419
+ detail = f"path={path}, is_dir={passed}"
420
+
421
+ elif validator.startswith("file ends with"):
422
+ match = re.search(r"\.(.+)", validator)
423
+ if match:
424
+ ext = "." + match.group(1)
425
+ path = result_obj.get("filePath") or result_obj.get("file_path") or ""
426
+ passed = path.endswith(ext)
427
+ detail = f"path={path}, ends_with={ext}"
428
+
429
+ else:
430
+ passed = False
431
+ detail = f"validator not implemented: {validator}"
432
+
433
+ if not passed:
434
+ all_passed = False
435
+
436
+ results.append({"validator": validator, "passed": passed, "detail": detail})
437
+
438
+ return all_passed, results
439
+
440
+
441
+ def generate_report(results: list[TestResult], output_path: str) -> str:
442
+ """Generate Markdown test report."""
443
+ total = len(results)
444
+ passed = sum(1 for r in results if r.passed)
445
+ failed = total - passed
446
+
447
+ categories = {}
448
+ for r in results:
449
+ if r.category not in categories:
450
+ categories[r.category] = {"passed": 0, "failed": 0}
451
+ if r.passed:
452
+ categories[r.category]["passed"] += 1
453
+ else:
454
+ categories[r.category]["failed"] += 1
455
+
456
+ lines = [
457
+ "# BioResearcher Plugin Test Report",
458
+ "",
459
+ f"**Run Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
460
+ f"**Total Tests:** {total}",
461
+ f"**Passed:** {passed}",
462
+ f"**Failed:** {failed}",
463
+ "",
464
+ "## Summary by Category",
465
+ "",
466
+ "| Category | Passed | Failed |",
467
+ "|----------|--------|--------|",
468
+ ]
469
+
470
+ for cat, stats in sorted(categories.items()):
471
+ total_cat = stats["passed"] + stats["failed"]
472
+ lines.append(
473
+ f"| {cat.title()} | {stats['passed']}/{total_cat} | {stats['failed']} |"
474
+ )
475
+
476
+ lines.extend(
477
+ [
478
+ "",
479
+ "## Skipped Tests",
480
+ "",
481
+ "### Database Tools (Not Tested)",
482
+ "- `dbQuery` - Requires live database connection",
483
+ "- `dbListTables` - Requires live database connection",
484
+ "- `dbDescribeTable` - Requires live database connection",
485
+ "",
486
+ "### Skills with User Interaction (Metadata Only)",
487
+ "- `python-setup-uv` - Uses Question tool",
488
+ "- `long-table-summary` - Uses Question tool",
489
+ "- `env-jsonc-setup` - Uses Question tool",
490
+ "- `pubmed-weekly` - Uses Question tool",
491
+ "",
492
+ ]
493
+ )
494
+
495
+ if failed > 0:
496
+ lines.extend(
497
+ [
498
+ "## Failed Tests",
499
+ "",
500
+ ]
501
+ )
502
+ for r in results:
503
+ if not r.passed:
504
+ lines.append(f"### {r.category.title()}: {r.test_name}")
505
+ lines.append(f"- **Tool:** `{r.tool}`")
506
+ lines.append(f"- **Expected:** {r.expected}")
507
+ if r.error:
508
+ lines.append(f"- **Error:** {r.error}")
509
+ for vr in r.validator_results:
510
+ if not vr["passed"]:
511
+ lines.append(
512
+ f" - Validator `{vr['validator']}`: {vr['detail']}"
513
+ )
514
+ lines.append("")
515
+
516
+ lines.extend(
517
+ [
518
+ "## Detailed Results",
519
+ "",
520
+ "| Test | Tool | Status |",
521
+ "|------|------|--------|",
522
+ ]
523
+ )
524
+
525
+ for r in results:
526
+ status = "PASS" if r.passed else "FAIL"
527
+ lines.append(f"| {r.test_name} | `{r.tool}` | {status} |")
528
+
529
+ report = "\n".join(lines)
530
+
531
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
532
+ with open(output_path, "w", encoding="utf-8") as f:
533
+ f.write(report)
534
+
535
+ return report
536
+
537
+
538
+ def list_tests(tests_by_category: dict[str, list[TestCase]]) -> None:
539
+ """Print all test cases."""
540
+ print("Available Test Cases")
541
+ print("=" * 60)
542
+
543
+ for category, tests in sorted(tests_by_category.items()):
544
+ print(f"\n[{category.upper()}] ({len(tests)} tests)")
545
+ for test in tests:
546
+ print(f" - {test.name} ({test.tool})")
547
+
548
+
549
+ def main():
550
+ parser = argparse.ArgumentParser(
551
+ description="BioResearcher Plugin Test Runner",
552
+ formatter_class=argparse.RawDescriptionHelpFormatter,
553
+ epilog="""
554
+ Examples:
555
+ uv run python test_runner.py --list
556
+ uv run python test_runner.py --category parser
557
+ uv run python test_runner.py --verbose
558
+ """,
559
+ )
560
+ parser.add_argument("--list", "-l", action="store_true", help="List all test cases")
561
+ parser.add_argument(
562
+ "--category", "-c", help="Show tests for specific category only"
563
+ )
564
+ parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
565
+ parser.add_argument(
566
+ "--output",
567
+ "-o",
568
+ default=".bioresearcher-tests/test_report.md",
569
+ help="Output report path",
570
+ )
571
+
572
+ args = parser.parse_args()
573
+
574
+ script_dir = os.path.dirname(os.path.abspath(__file__))
575
+ test_cases_dir = os.path.join(script_dir, "test_cases")
576
+
577
+ tests_by_category = load_all_tests(test_cases_dir)
578
+
579
+ if args.list:
580
+ if args.category:
581
+ if args.category in tests_by_category:
582
+ tests = {args.category: tests_by_category[args.category]}
583
+ list_tests(tests)
584
+ else:
585
+ print(f"Unknown category: {args.category}")
586
+ print(f"Available: {', '.join(tests_by_category.keys())}")
587
+ else:
588
+ list_tests(tests_by_category)
589
+ return
590
+
591
+ print("BioResearcher Plugin Test Runner")
592
+ print("=" * 40)
593
+ print()
594
+ print("This script provides test structure utilities.")
595
+ print()
596
+ print("To run actual tests:")
597
+ print(" 1. Load skill: skill bioresearcher-tests")
598
+ print(" 2. Extract skill_path from output")
599
+ print(" 3. Follow workflow steps in SKILL.md")
600
+ print()
601
+ print(f"Total test cases: {sum(len(t) for t in tests_by_category.values())}")
602
+ for cat, tests in sorted(tests_by_category.items()):
603
+ print(f" - {cat}: {len(tests)} tests")
604
+
605
+
606
+ if __name__ == "__main__":
607
+ main()