opencastle 0.26.1 → 0.27.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 (226) hide show
  1. package/README.md +7 -1
  2. package/bin/cli.mjs +10 -0
  3. package/dist/cli/agents.d.ts +3 -0
  4. package/dist/cli/agents.d.ts.map +1 -0
  5. package/dist/cli/agents.js +161 -0
  6. package/dist/cli/agents.js.map +1 -0
  7. package/dist/cli/baselines.d.ts +3 -0
  8. package/dist/cli/baselines.d.ts.map +1 -0
  9. package/dist/cli/baselines.js +128 -0
  10. package/dist/cli/baselines.js.map +1 -0
  11. package/dist/cli/convoy/engine.d.ts +68 -2
  12. package/dist/cli/convoy/engine.d.ts.map +1 -1
  13. package/dist/cli/convoy/engine.js +2102 -26
  14. package/dist/cli/convoy/engine.js.map +1 -1
  15. package/dist/cli/convoy/engine.test.js +1572 -70
  16. package/dist/cli/convoy/engine.test.js.map +1 -1
  17. package/dist/cli/convoy/events.d.ts +4 -1
  18. package/dist/cli/convoy/events.d.ts.map +1 -1
  19. package/dist/cli/convoy/events.js +74 -13
  20. package/dist/cli/convoy/events.js.map +1 -1
  21. package/dist/cli/convoy/events.test.js +154 -27
  22. package/dist/cli/convoy/events.test.js.map +1 -1
  23. package/dist/cli/convoy/expertise.d.ts +16 -0
  24. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  25. package/dist/cli/convoy/expertise.js +121 -0
  26. package/dist/cli/convoy/expertise.js.map +1 -0
  27. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  28. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  29. package/dist/cli/convoy/expertise.test.js +96 -0
  30. package/dist/cli/convoy/expertise.test.js.map +1 -0
  31. package/dist/cli/convoy/export.test.js +1 -0
  32. package/dist/cli/convoy/export.test.js.map +1 -1
  33. package/dist/cli/convoy/formula.d.ts +19 -0
  34. package/dist/cli/convoy/formula.d.ts.map +1 -0
  35. package/dist/cli/convoy/formula.js +142 -0
  36. package/dist/cli/convoy/formula.js.map +1 -0
  37. package/dist/cli/convoy/formula.test.d.ts +2 -0
  38. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  39. package/dist/cli/convoy/formula.test.js +342 -0
  40. package/dist/cli/convoy/formula.test.js.map +1 -0
  41. package/dist/cli/convoy/gates.d.ts +128 -0
  42. package/dist/cli/convoy/gates.d.ts.map +1 -0
  43. package/dist/cli/convoy/gates.js +606 -0
  44. package/dist/cli/convoy/gates.js.map +1 -0
  45. package/dist/cli/convoy/gates.test.d.ts +2 -0
  46. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  47. package/dist/cli/convoy/gates.test.js +976 -0
  48. package/dist/cli/convoy/gates.test.js.map +1 -0
  49. package/dist/cli/convoy/health.d.ts +11 -0
  50. package/dist/cli/convoy/health.d.ts.map +1 -1
  51. package/dist/cli/convoy/health.js +54 -0
  52. package/dist/cli/convoy/health.js.map +1 -1
  53. package/dist/cli/convoy/health.test.js +56 -1
  54. package/dist/cli/convoy/health.test.js.map +1 -1
  55. package/dist/cli/convoy/issues.d.ts +8 -0
  56. package/dist/cli/convoy/issues.d.ts.map +1 -0
  57. package/dist/cli/convoy/issues.js +98 -0
  58. package/dist/cli/convoy/issues.js.map +1 -0
  59. package/dist/cli/convoy/issues.test.d.ts +2 -0
  60. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  61. package/dist/cli/convoy/issues.test.js +107 -0
  62. package/dist/cli/convoy/issues.test.js.map +1 -0
  63. package/dist/cli/convoy/knowledge.d.ts +5 -0
  64. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  65. package/dist/cli/convoy/knowledge.js +116 -0
  66. package/dist/cli/convoy/knowledge.js.map +1 -0
  67. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  68. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  69. package/dist/cli/convoy/knowledge.test.js +87 -0
  70. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  71. package/dist/cli/convoy/lessons.d.ts +17 -0
  72. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  73. package/dist/cli/convoy/lessons.js +149 -0
  74. package/dist/cli/convoy/lessons.js.map +1 -0
  75. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  76. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  77. package/dist/cli/convoy/lessons.test.js +135 -0
  78. package/dist/cli/convoy/lessons.test.js.map +1 -0
  79. package/dist/cli/convoy/lock.d.ts +13 -0
  80. package/dist/cli/convoy/lock.d.ts.map +1 -0
  81. package/dist/cli/convoy/lock.js +88 -0
  82. package/dist/cli/convoy/lock.js.map +1 -0
  83. package/dist/cli/convoy/lock.test.d.ts +2 -0
  84. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  85. package/dist/cli/convoy/lock.test.js +136 -0
  86. package/dist/cli/convoy/lock.test.js.map +1 -0
  87. package/dist/cli/convoy/merge.d.ts +4 -0
  88. package/dist/cli/convoy/merge.d.ts.map +1 -1
  89. package/dist/cli/convoy/merge.js +18 -1
  90. package/dist/cli/convoy/merge.js.map +1 -1
  91. package/dist/cli/convoy/merge.test.js +6 -7
  92. package/dist/cli/convoy/merge.test.js.map +1 -1
  93. package/dist/cli/convoy/partition.d.ts +51 -0
  94. package/dist/cli/convoy/partition.d.ts.map +1 -0
  95. package/dist/cli/convoy/partition.js +186 -0
  96. package/dist/cli/convoy/partition.js.map +1 -0
  97. package/dist/cli/convoy/partition.test.d.ts +2 -0
  98. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  99. package/dist/cli/convoy/partition.test.js +315 -0
  100. package/dist/cli/convoy/partition.test.js.map +1 -0
  101. package/dist/cli/convoy/pipeline.test.js +6 -0
  102. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  103. package/dist/cli/convoy/store.d.ts +47 -5
  104. package/dist/cli/convoy/store.d.ts.map +1 -1
  105. package/dist/cli/convoy/store.js +525 -19
  106. package/dist/cli/convoy/store.js.map +1 -1
  107. package/dist/cli/convoy/store.test.js +1345 -12
  108. package/dist/cli/convoy/store.test.js.map +1 -1
  109. package/dist/cli/convoy/types.d.ts +156 -2
  110. package/dist/cli/convoy/types.d.ts.map +1 -1
  111. package/dist/cli/destroy.d.ts +3 -0
  112. package/dist/cli/destroy.d.ts.map +1 -0
  113. package/dist/cli/destroy.js +69 -0
  114. package/dist/cli/destroy.js.map +1 -0
  115. package/dist/cli/destroy.test.d.ts +2 -0
  116. package/dist/cli/destroy.test.d.ts.map +1 -0
  117. package/dist/cli/destroy.test.js +116 -0
  118. package/dist/cli/destroy.test.js.map +1 -0
  119. package/dist/cli/gitignore.d.ts +9 -0
  120. package/dist/cli/gitignore.d.ts.map +1 -1
  121. package/dist/cli/gitignore.js +29 -0
  122. package/dist/cli/gitignore.js.map +1 -1
  123. package/dist/cli/plan.d.ts +3 -0
  124. package/dist/cli/plan.d.ts.map +1 -0
  125. package/dist/cli/plan.js +288 -0
  126. package/dist/cli/plan.js.map +1 -0
  127. package/dist/cli/run/adapters/claude.d.ts +2 -0
  128. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  129. package/dist/cli/run/adapters/claude.js +89 -49
  130. package/dist/cli/run/adapters/claude.js.map +1 -1
  131. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  132. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  133. package/dist/cli/run/adapters/claude.test.js +205 -0
  134. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  135. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  137. package/dist/cli/run/adapters/copilot.js +84 -46
  138. package/dist/cli/run/adapters/copilot.js.map +1 -1
  139. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  140. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  141. package/dist/cli/run/adapters/copilot.test.js +195 -0
  142. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  143. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  145. package/dist/cli/run/adapters/cursor.js +83 -47
  146. package/dist/cli/run/adapters/cursor.js.map +1 -1
  147. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  148. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  149. package/dist/cli/run/adapters/cursor.test.js +129 -0
  150. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  151. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  153. package/dist/cli/run/adapters/opencode.js +81 -47
  154. package/dist/cli/run/adapters/opencode.js.map +1 -1
  155. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  156. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  157. package/dist/cli/run/adapters/opencode.test.js +119 -0
  158. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  159. package/dist/cli/run/executor.js +1 -1
  160. package/dist/cli/run/executor.js.map +1 -1
  161. package/dist/cli/run/schema.d.ts.map +1 -1
  162. package/dist/cli/run/schema.js +245 -4
  163. package/dist/cli/run/schema.js.map +1 -1
  164. package/dist/cli/run/schema.test.js +669 -0
  165. package/dist/cli/run/schema.test.js.map +1 -1
  166. package/dist/cli/run.d.ts.map +1 -1
  167. package/dist/cli/run.js +362 -22
  168. package/dist/cli/run.js.map +1 -1
  169. package/dist/cli/types.d.ts +85 -2
  170. package/dist/cli/types.d.ts.map +1 -1
  171. package/dist/cli/types.js.map +1 -1
  172. package/dist/cli/watch.d.ts +15 -0
  173. package/dist/cli/watch.d.ts.map +1 -0
  174. package/dist/cli/watch.js +279 -0
  175. package/dist/cli/watch.js.map +1 -0
  176. package/package.json +1 -1
  177. package/src/cli/agents.ts +177 -0
  178. package/src/cli/baselines.ts +143 -0
  179. package/src/cli/convoy/engine.test.ts +1839 -70
  180. package/src/cli/convoy/engine.ts +2417 -38
  181. package/src/cli/convoy/events.test.ts +179 -38
  182. package/src/cli/convoy/events.ts +88 -16
  183. package/src/cli/convoy/expertise.test.ts +128 -0
  184. package/src/cli/convoy/expertise.ts +163 -0
  185. package/src/cli/convoy/export.test.ts +1 -0
  186. package/src/cli/convoy/formula.test.ts +405 -0
  187. package/src/cli/convoy/formula.ts +174 -0
  188. package/src/cli/convoy/gates.test.ts +1169 -0
  189. package/src/cli/convoy/gates.ts +774 -0
  190. package/src/cli/convoy/health.test.ts +64 -2
  191. package/src/cli/convoy/health.ts +80 -2
  192. package/src/cli/convoy/issues.test.ts +143 -0
  193. package/src/cli/convoy/issues.ts +136 -0
  194. package/src/cli/convoy/knowledge.test.ts +101 -0
  195. package/src/cli/convoy/knowledge.ts +132 -0
  196. package/src/cli/convoy/lessons.test.ts +188 -0
  197. package/src/cli/convoy/lessons.ts +164 -0
  198. package/src/cli/convoy/lock.test.ts +181 -0
  199. package/src/cli/convoy/lock.ts +103 -0
  200. package/src/cli/convoy/merge.test.ts +6 -7
  201. package/src/cli/convoy/merge.ts +19 -1
  202. package/src/cli/convoy/partition.test.ts +423 -0
  203. package/src/cli/convoy/partition.ts +232 -0
  204. package/src/cli/convoy/pipeline.test.ts +6 -0
  205. package/src/cli/convoy/store.test.ts +1512 -14
  206. package/src/cli/convoy/store.ts +676 -30
  207. package/src/cli/convoy/types.ts +170 -1
  208. package/src/cli/destroy.test.ts +141 -0
  209. package/src/cli/destroy.ts +88 -0
  210. package/src/cli/gitignore.ts +36 -0
  211. package/src/cli/plan.ts +316 -0
  212. package/src/cli/run/adapters/claude.test.ts +234 -0
  213. package/src/cli/run/adapters/claude.ts +45 -5
  214. package/src/cli/run/adapters/copilot.test.ts +224 -0
  215. package/src/cli/run/adapters/copilot.ts +34 -4
  216. package/src/cli/run/adapters/cursor.test.ts +144 -0
  217. package/src/cli/run/adapters/cursor.ts +33 -2
  218. package/src/cli/run/adapters/opencode.test.ts +135 -0
  219. package/src/cli/run/adapters/opencode.ts +30 -2
  220. package/src/cli/run/executor.ts +1 -1
  221. package/src/cli/run/schema.test.ts +758 -0
  222. package/src/cli/run/schema.ts +300 -25
  223. package/src/cli/run.ts +341 -21
  224. package/src/cli/types.ts +86 -1
  225. package/src/cli/watch.ts +298 -0
  226. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -0,0 +1,1169 @@
1
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, realpathSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { deflateSync } from 'node:zlib'
5
+ import { EventEmitter } from 'node:events'
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
7
+ import {
8
+ scanForSecrets,
9
+ withSecretScan,
10
+ runBlastRadiusGate,
11
+ runGateCommand,
12
+ browserTestGate,
13
+ runA11yAudit,
14
+ _setAllowlistConfigPath,
15
+ _resetAllowlistCache,
16
+ pixelDiffPercentage,
17
+ computeVisualDiff,
18
+ captureAndPersistBaseline,
19
+ mapA11ySeverity,
20
+ type A11yFinding,
21
+ } from './gates.js'
22
+
23
+ // ── Mock child_process for timeout tests ──────────────────────────────────────
24
+
25
+ vi.mock('node:child_process', () => ({
26
+ spawn: vi.fn(),
27
+ }))
28
+
29
+ // ── Helpers ───────────────────────────────────────────────────────────────────
30
+
31
+ function makeFakeChild() {
32
+ const proc = new EventEmitter() as NodeJS.EventEmitter & {
33
+ stdout: EventEmitter
34
+ stderr: EventEmitter
35
+ kill: ReturnType<typeof vi.fn>
36
+ }
37
+ Object.assign(proc, {
38
+ stdout: new EventEmitter(),
39
+ stderr: new EventEmitter(),
40
+ kill: vi.fn(),
41
+ })
42
+ return proc
43
+ }
44
+
45
+ // ── scanForSecrets ────────────────────────────────────────────────────────────
46
+
47
+ describe('scanForSecrets', () => {
48
+ beforeEach(() => {
49
+ _resetAllowlistCache()
50
+ _setAllowlistConfigPath('/nonexistent/path/does/not/exist.yml')
51
+ })
52
+
53
+ it('returns clean result for safe content', () => {
54
+ const result = scanForSecrets('const x = 1\nconsole.log(x)\n// no secrets here')
55
+ expect(result.clean).toBe(true)
56
+ expect(result.findings).toHaveLength(0)
57
+ })
58
+
59
+ it('detects AWS Access Key', () => {
60
+ const result = scanForSecrets('key: AKIAIOSFODNN7EXAMPLE')
61
+ expect(result.clean).toBe(false)
62
+ expect(result.findings[0].pattern).toBe('AWS Access Key')
63
+ expect(result.findings[0].line).toBe(1)
64
+ })
65
+
66
+ it('detects AWS Secret Key', () => {
67
+ const result = scanForSecrets(
68
+ 'aws_secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
69
+ )
70
+ expect(result.clean).toBe(false)
71
+ expect(result.findings[0].pattern).toBe('AWS Secret Key')
72
+ })
73
+
74
+ it('detects Generic API Key', () => {
75
+ const result = scanForSecrets('apikey: Xb3kR7mNpQvZwY4hJcFe9ABCDE')
76
+ expect(result.clean).toBe(false)
77
+ expect(result.findings[0].pattern).toBe('Generic API Key')
78
+ })
79
+
80
+ it('detects Bearer Token', () => {
81
+ const result = scanForSecrets('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig')
82
+ expect(result.clean).toBe(false)
83
+ expect(result.findings[0].pattern).toBe('Bearer Token')
84
+ })
85
+
86
+ it('detects Private Key header', () => {
87
+ const result = scanForSecrets('-----BEGIN RSA PRIVATE KEY-----')
88
+ expect(result.clean).toBe(false)
89
+ expect(result.findings[0].pattern).toBe('Private Key')
90
+ })
91
+
92
+ it('detects Connection String', () => {
93
+ const result = scanForSecrets('postgres://dbuser:s3cr3tpass@localhost:5432/mydb')
94
+ expect(result.clean).toBe(false)
95
+ expect(result.findings[0].pattern).toBe('Connection String')
96
+ })
97
+
98
+ it('detects GitHub Token', () => {
99
+ const result = scanForSecrets(
100
+ 'token: ghp_abcdefghijklmnopqrstuvwxyz12345678ABCD',
101
+ )
102
+ expect(result.clean).toBe(false)
103
+ expect(result.findings[0].pattern).toBe('GitHub Token')
104
+ })
105
+
106
+ it('detects Generic Password', () => {
107
+ const result = scanForSecrets('password: Sup3rS3cur3Pass')
108
+ expect(result.clean).toBe(false)
109
+ expect(result.findings[0].pattern).toBe('Generic Password')
110
+ })
111
+
112
+ it('detects Slack Token', () => {
113
+ const result = scanForSecrets('xoxb-12345678901-ABCDEFGHIJKLM')
114
+ expect(result.clean).toBe(false)
115
+ expect(result.findings[0].pattern).toBe('Slack Token')
116
+ })
117
+
118
+ it('detects Generic Secret', () => {
119
+ const result = scanForSecrets('secret: my_unique_sec_token_value_1234')
120
+ expect(result.clean).toBe(false)
121
+ expect(result.findings[0].pattern).toBe('Generic Secret')
122
+ })
123
+
124
+ it('includes file and line info in findings', () => {
125
+ const result = scanForSecrets('ok line\npassword: s3cur3P4ssw0rd\n', 'src/config.ts')
126
+ expect(result.findings[0].file).toBe('src/config.ts')
127
+ expect(result.findings[0].line).toBe(2)
128
+ })
129
+
130
+ it('truncates long line snippet to ~100 chars', () => {
131
+ const longLine = 'password: ' + 'x'.repeat(200)
132
+ const result = scanForSecrets(longLine)
133
+ expect(result.findings[0].snippet.length).toBeLessThanOrEqual(103)
134
+ })
135
+ })
136
+
137
+ // ── withSecretScan ────────────────────────────────────────────────────────────
138
+
139
+ describe('withSecretScan', () => {
140
+ beforeEach(() => {
141
+ _resetAllowlistCache()
142
+ _setAllowlistConfigPath('/nonexistent/path/does/not/exist.yml')
143
+ })
144
+
145
+ it('calls writeAction when content is clean', () => {
146
+ const writeAction = vi.fn()
147
+ const onBlock = vi.fn()
148
+ withSecretScan('const x = 1', writeAction, onBlock)
149
+ expect(writeAction).toHaveBeenCalledOnce()
150
+ expect(onBlock).not.toHaveBeenCalled()
151
+ })
152
+
153
+ it('calls onBlock with findings when secrets are detected', () => {
154
+ const writeAction = vi.fn()
155
+ const onBlock = vi.fn()
156
+ withSecretScan('key: AKIAIOSFODNN7EXAMPLE', writeAction, onBlock)
157
+ expect(writeAction).not.toHaveBeenCalled()
158
+ expect(onBlock).toHaveBeenCalledOnce()
159
+ const findings = onBlock.mock.calls[0][0] as Array<{ pattern: string }>
160
+ expect(findings[0].pattern).toBe('AWS Access Key')
161
+ })
162
+ })
163
+
164
+ // ── Allowlist config ──────────────────────────────────────────────────────────
165
+
166
+ describe('allowlist config', () => {
167
+ let tmpDir: string
168
+
169
+ beforeEach(() => {
170
+ tmpDir = mkdtempSync(join(tmpdir(), 'gates-test-'))
171
+ _resetAllowlistCache()
172
+ })
173
+
174
+ afterEach(() => {
175
+ _resetAllowlistCache()
176
+ rmSync(tmpDir, { recursive: true, force: true })
177
+ })
178
+
179
+ it('pattern+paths suppresses findings in matching file', () => {
180
+ const configPath = join(tmpDir, 'secret-scan-config.yml')
181
+ writeFileSync(
182
+ configPath,
183
+ 'allowlist:\n - pattern: "AKIA[0-9A-Z]{16}"\n reason: "test key"\n paths:\n - ".test."\n',
184
+ )
185
+ _setAllowlistConfigPath(configPath)
186
+ const result = scanForSecrets('key: AKIAIOSFODNN7EXAMPLE', 'src/my.test.ts')
187
+ expect(result.clean).toBe(true)
188
+ })
189
+
190
+ it('pattern+paths does NOT suppress in non-matching file', () => {
191
+ const configPath = join(tmpDir, 'secret-scan-config.yml')
192
+ writeFileSync(
193
+ configPath,
194
+ 'allowlist:\n - pattern: "AKIA[0-9A-Z]{16}"\n reason: "test key"\n paths:\n - ".test."\n',
195
+ )
196
+ _setAllowlistConfigPath(configPath)
197
+ const result = scanForSecrets('key: AKIAIOSFODNN7EXAMPLE', 'src/config.ts')
198
+ expect(result.clean).toBe(false)
199
+ })
200
+
201
+ it('literal suppresses exact string match in any file', () => {
202
+ const configPath = join(tmpDir, 'secret-scan-config.yml')
203
+ writeFileSync(
204
+ configPath,
205
+ 'allowlist:\n - literal: "AKIAIOSFODNN7EXAMPLE"\n reason: "example key in docs"\n',
206
+ )
207
+ _setAllowlistConfigPath(configPath)
208
+ const result = scanForSecrets('key: AKIAIOSFODNN7EXAMPLE', 'src/config.ts')
209
+ expect(result.clean).toBe(true)
210
+ })
211
+
212
+ it('literal without paths applies across all files', () => {
213
+ const configPath = join(tmpDir, 'secret-scan-config.yml')
214
+ writeFileSync(
215
+ configPath,
216
+ 'allowlist:\n - literal: "AKIAIOSFODNN7EXAMPLE"\n reason: "example key"\n',
217
+ )
218
+ _setAllowlistConfigPath(configPath)
219
+ // Should suppress in any file
220
+ expect(scanForSecrets('key: AKIAIOSFODNN7EXAMPLE', 'src/any.ts').clean).toBe(true)
221
+ expect(scanForSecrets('key: AKIAIOSFODNN7EXAMPLE', 'README.md').clean).toBe(true)
222
+ })
223
+ })
224
+
225
+ // ── runBlastRadiusGate ────────────────────────────────────────────────────────
226
+
227
+ describe('runBlastRadiusGate', () => {
228
+ it('passes (ok) under all thresholds', () => {
229
+ const diff = Array(100).fill('+const x = 1').join('\n')
230
+ const result = runBlastRadiusGate(diff)
231
+ expect(result.passed).toBe(true)
232
+ expect(result.level).toBe('ok')
233
+ })
234
+
235
+ it('warns (passed=true) at exactly 200 lines changed', () => {
236
+ const diff = Array(200).fill('+const x = 1').join('\n')
237
+ const result = runBlastRadiusGate(diff)
238
+ expect(result.passed).toBe(true)
239
+ expect(result.level).toBe('warn')
240
+ })
241
+
242
+ it('warns at 201 lines changed', () => {
243
+ const diff = Array(201).fill('+const x = 1').join('\n')
244
+ const result = runBlastRadiusGate(diff)
245
+ expect(result.passed).toBe(true)
246
+ expect(result.level).toBe('warn')
247
+ })
248
+
249
+ it('blocks (passed=false) at exactly 500 lines changed', () => {
250
+ const diff = Array(500).fill('+const x = 1').join('\n')
251
+ const result = runBlastRadiusGate(diff)
252
+ expect(result.passed).toBe(false)
253
+ expect(result.level).toBe('block')
254
+ })
255
+
256
+ it('blocks at 501 lines changed', () => {
257
+ const diff = Array(501).fill('+const x = 1').join('\n')
258
+ const result = runBlastRadiusGate(diff)
259
+ expect(result.passed).toBe(false)
260
+ expect(result.level).toBe('block')
261
+ })
262
+
263
+ it('warns at exactly 5 files changed', () => {
264
+ const diff = Array.from(
265
+ { length: 5 },
266
+ (_, i) => `diff --git a/file${i}.ts b/file${i}.ts\n+const x = 1`,
267
+ ).join('\n')
268
+ const result = runBlastRadiusGate(diff)
269
+ expect(result.passed).toBe(true)
270
+ expect(result.level).toBe('warn')
271
+ })
272
+
273
+ it('blocks at 10 files changed', () => {
274
+ const diff = Array.from(
275
+ { length: 10 },
276
+ (_, i) => `diff --git a/file${i}.ts b/file${i}.ts\n+const x = 1`,
277
+ ).join('\n')
278
+ const result = runBlastRadiusGate(diff)
279
+ expect(result.passed).toBe(false)
280
+ expect(result.level).toBe('block')
281
+ })
282
+
283
+ it('does not count +++ and --- header lines as changes', () => {
284
+ const diff = '+++ b/file.ts\n--- a/file.ts\n+const x = 1'
285
+ const result = runBlastRadiusGate(diff)
286
+ expect(result.level).toBe('ok')
287
+ })
288
+
289
+ it('output contains line and file counts', () => {
290
+ const diff = Array(50).fill('+const x = 1').join('\n')
291
+ const result = runBlastRadiusGate(diff)
292
+ expect(result.output).toContain('50 lines changed')
293
+ expect(result.output).toContain('0 files changed')
294
+ })
295
+ })
296
+
297
+ // ── Gate timeout: SIGTERM then SIGKILL ────────────────────────────────────────
298
+
299
+ describe('gate timeout', () => {
300
+ beforeEach(() => {
301
+ vi.useFakeTimers()
302
+ })
303
+
304
+ afterEach(() => {
305
+ vi.useRealTimers()
306
+ vi.clearAllMocks()
307
+ })
308
+
309
+ it('sends SIGTERM at timeout then SIGKILL after 5s', async () => {
310
+ const { spawn } = await import('node:child_process')
311
+ const mockChild = makeFakeChild()
312
+ vi.mocked(spawn).mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>)
313
+
314
+ const resultPromise = runGateCommand('test-cmd', ['--arg'], '/tmp', 100)
315
+
316
+ // Advance 100ms — SIGTERM should fire
317
+ vi.advanceTimersByTime(100)
318
+ expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM')
319
+ expect(mockChild.kill).not.toHaveBeenCalledWith('SIGKILL')
320
+
321
+ // Advance 5s more — SIGKILL should fire
322
+ vi.advanceTimersByTime(5_000)
323
+ expect(mockChild.kill).toHaveBeenCalledWith('SIGKILL')
324
+
325
+ // Settle the promise
326
+ mockChild.emit('close', -1)
327
+ const result = await resultPromise
328
+ expect(result.timedOut).toBe(true)
329
+ expect(result.exitCode).toBe(-1)
330
+ })
331
+
332
+ it('does NOT send SIGKILL if process closes before 5s deadline', async () => {
333
+ const { spawn } = await import('node:child_process')
334
+ const mockChild = makeFakeChild()
335
+ vi.mocked(spawn).mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>)
336
+
337
+ const resultPromise = runGateCommand('test-cmd', [], '/tmp', 100)
338
+
339
+ // Trigger SIGTERM
340
+ vi.advanceTimersByTime(100)
341
+ expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM')
342
+
343
+ // Process closes immediately after SIGTERM (before 5s)
344
+ mockChild.emit('close', 0)
345
+ await resultPromise
346
+
347
+ // Advance past the 5s sigkill window — no additional kill call
348
+ vi.advanceTimersByTime(5_000)
349
+ expect(mockChild.kill).toHaveBeenCalledTimes(1)
350
+ })
351
+
352
+ it('resolves without SIGTERM when process completes before timeout', async () => {
353
+ const { spawn } = await import('node:child_process')
354
+ const mockChild = makeFakeChild()
355
+ vi.mocked(spawn).mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>)
356
+
357
+ const resultPromise = runGateCommand('test-cmd', [], '/tmp', 30_000)
358
+
359
+ // Process completes quickly
360
+ mockChild.emit('close', 0)
361
+ const result = await resultPromise
362
+
363
+ expect(result.timedOut).toBe(false)
364
+ expect(result.exitCode).toBe(0)
365
+ expect(mockChild.kill).not.toHaveBeenCalled()
366
+ })
367
+ })
368
+
369
+ // ── browserTestGate ───────────────────────────────────────────────────────────
370
+
371
+ describe('browserTestGate', () => {
372
+ beforeEach(() => {
373
+ vi.useFakeTimers()
374
+ })
375
+
376
+ afterEach(() => {
377
+ vi.useRealTimers()
378
+ vi.clearAllMocks()
379
+ _resetAllowlistCache()
380
+ _setAllowlistConfigPath('/nonexistent/path/does/not/exist.yml')
381
+ })
382
+
383
+ const browserServer = { name: 'playwright-browser', type: 'browser', command: 'npx', args: ['playwright'] }
384
+
385
+ it('rejects non-local URLs (SSRF prevention)', async () => {
386
+ const resultPromise = browserTestGate({
387
+ mcpServers: [browserServer],
388
+ taskConfig: { urls: ['https://example.com'] },
389
+ worktreePath: '/tmp',
390
+ })
391
+ vi.runAllTimersAsync()
392
+ const result = await resultPromise
393
+ expect(result.passed).toBe(false)
394
+ expect(result.output).toContain('is not a local address')
395
+ })
396
+
397
+ it('returns fail when no browser-capable MCP server found', async () => {
398
+ const resultPromise = browserTestGate({
399
+ mcpServers: [],
400
+ taskConfig: { urls: ['http://localhost:3000'] },
401
+ worktreePath: '/tmp',
402
+ })
403
+ vi.runAllTimersAsync()
404
+ const result = await resultPromise
405
+ expect(result.passed).toBe(false)
406
+ expect(result.output).toContain('no browser-capable MCP server found')
407
+ })
408
+
409
+ it('returns fail when no MCP server matches browser type', async () => {
410
+ const resultPromise = browserTestGate({
411
+ mcpServers: [{ name: 'my-tool', type: 'filesystem', command: 'node' }],
412
+ taskConfig: { urls: ['http://localhost:3000'] },
413
+ worktreePath: '/tmp',
414
+ })
415
+ vi.runAllTimersAsync()
416
+ const result = await resultPromise
417
+ expect(result.passed).toBe(false)
418
+ expect(result.output).toContain('no browser-capable MCP server found')
419
+ })
420
+
421
+ it('returns pass for successful localhost HTTP check (HTTP 200)', async () => {
422
+ const { spawn } = await import('node:child_process')
423
+ const mockChild = makeFakeChild()
424
+ vi.mocked(spawn).mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>)
425
+
426
+ // Server without command — so only curl is called
427
+ const serverNoCmd = { name: 'playwright', type: 'browser' }
428
+ const resultPromise = browserTestGate({
429
+ mcpServers: [serverNoCmd],
430
+ taskConfig: { urls: ['http://localhost:3000'] },
431
+ worktreePath: '/tmp',
432
+ })
433
+
434
+ // Emit curl stdout (HTTP 200) and close
435
+ mockChild.stdout.emit('data', Buffer.from('200'))
436
+ mockChild.emit('close', 0)
437
+ vi.runAllTimersAsync()
438
+
439
+ const result = await resultPromise
440
+ expect(result.passed).toBe(true)
441
+ expect(result.output).toContain('PASS')
442
+ expect(result.output).toContain('HTTP 200')
443
+ })
444
+
445
+ it('returns fail for HTTP 500 response', async () => {
446
+ const { spawn } = await import('node:child_process')
447
+ const mockChild = makeFakeChild()
448
+ vi.mocked(spawn).mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>)
449
+
450
+ const serverNoCmd = { name: 'playwright', type: 'browser' }
451
+ const resultPromise = browserTestGate({
452
+ mcpServers: [serverNoCmd],
453
+ taskConfig: { urls: ['http://localhost:3000'] },
454
+ worktreePath: '/tmp',
455
+ })
456
+
457
+ mockChild.stdout.emit('data', Buffer.from('500'))
458
+ mockChild.emit('close', 0)
459
+ vi.runAllTimersAsync()
460
+
461
+ const result = await resultPromise
462
+ expect(result.passed).toBe(false)
463
+ expect(result.output).toContain('FAIL')
464
+ expect(result.output).toContain('HTTP 500')
465
+ })
466
+
467
+ it('returns fail when curl times out', async () => {
468
+ const { spawn } = await import('node:child_process')
469
+ const mockChild = makeFakeChild()
470
+ vi.mocked(spawn).mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>)
471
+
472
+ const serverNoCmd = { name: 'playwright', type: 'browser' }
473
+ const resultPromise = browserTestGate({
474
+ mcpServers: [serverNoCmd],
475
+ taskConfig: { urls: ['http://localhost:3000'] },
476
+ worktreePath: '/tmp',
477
+ })
478
+
479
+ // Advance time to trigger curl timeout (35s)
480
+ vi.advanceTimersByTime(35_000)
481
+ mockChild.emit('close', -1)
482
+ vi.runAllTimersAsync()
483
+
484
+ const result = await resultPromise
485
+ expect(result.passed).toBe(false)
486
+ expect(result.output).toContain('timed out')
487
+ })
488
+
489
+ it('detects console errors when check_console_errors enabled', async () => {
490
+ const { spawn } = await import('node:child_process')
491
+ // First call: curl (HTTP 200), second call: browser automation (with [console.error])
492
+ let callCount = 0
493
+ vi.mocked(spawn).mockImplementation(() => {
494
+ callCount++
495
+ const mockChild = makeFakeChild()
496
+ if (callCount === 1) {
497
+ // curl
498
+ Promise.resolve().then(() => {
499
+ mockChild.stdout.emit('data', Buffer.from('200'))
500
+ mockChild.emit('close', 0)
501
+ })
502
+ } else {
503
+ // browser automation — output contains console.error
504
+ Promise.resolve().then(() => {
505
+ mockChild.stdout.emit('data', Buffer.from('test passed\n[console.error] Uncaught TypeError'))
506
+ mockChild.emit('close', 0)
507
+ })
508
+ }
509
+ return mockChild as unknown as ReturnType<typeof spawn>
510
+ })
511
+
512
+ const resultPromise = browserTestGate({
513
+ mcpServers: [browserServer],
514
+ taskConfig: { urls: ['http://localhost:3000'], check_console_errors: true },
515
+ worktreePath: '/tmp',
516
+ })
517
+ vi.runAllTimersAsync()
518
+
519
+ const result = await resultPromise
520
+ expect(result.passed).toBe(false)
521
+ expect(result.output).toContain('Console errors detected')
522
+ })
523
+
524
+ it('secret-scans browser automation output', async () => {
525
+ const { spawn } = await import('node:child_process')
526
+ _setAllowlistConfigPath('/nonexistent/path/does/not/exist.yml')
527
+ _resetAllowlistCache()
528
+
529
+ let callCount = 0
530
+ vi.mocked(spawn).mockImplementation(() => {
531
+ callCount++
532
+ const mockChild = makeFakeChild()
533
+ if (callCount === 1) {
534
+ // curl
535
+ Promise.resolve().then(() => {
536
+ mockChild.stdout.emit('data', Buffer.from('200'))
537
+ mockChild.emit('close', 0)
538
+ })
539
+ } else {
540
+ // Browser output with a secret
541
+ Promise.resolve().then(() => {
542
+ mockChild.stdout.emit('data', Buffer.from('ghp_abcdefghijklmnopqrstuvwxyz12345678ABCD'))
543
+ mockChild.emit('close', 0)
544
+ })
545
+ }
546
+ return mockChild as unknown as ReturnType<typeof spawn>
547
+ })
548
+
549
+ const resultPromise = browserTestGate({
550
+ mcpServers: [browserServer],
551
+ taskConfig: { urls: ['http://localhost:3000'] },
552
+ worktreePath: '/tmp',
553
+ })
554
+ vi.runAllTimersAsync()
555
+
556
+ const result = await resultPromise
557
+ // Output should mention secrets were redacted, not expose them
558
+ expect(result.output).toContain('potential secrets (redacted)')
559
+ expect(result.output).not.toContain('ghp_')
560
+ })
561
+
562
+ // ── Visual diff integration ────────────────────────────────────────────────
563
+
564
+ describe('visual diff integration', () => {
565
+ let diffTmpDir: string
566
+
567
+ beforeEach(() => {
568
+ diffTmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'browser-vd-')))
569
+ })
570
+
571
+ afterEach(() => {
572
+ rmSync(diffTmpDir, { recursive: true, force: true })
573
+ })
574
+
575
+ const serverWithCmd = { name: 'playwright', type: 'browser', command: 'npx', args: ['playwright'] }
576
+
577
+ it('visual diff: passes when no baseline exists (first run skips diff)', async () => {
578
+ const { spawn } = await import('node:child_process')
579
+ let callCount = 0
580
+ vi.mocked(spawn).mockImplementation(() => {
581
+ callCount++
582
+ const child = makeFakeChild()
583
+ Promise.resolve().then(() => {
584
+ if (callCount === 1) child.stdout.emit('data', Buffer.from('200')) // curl
585
+ child.emit('close', 0)
586
+ })
587
+ return child as unknown as ReturnType<typeof spawn>
588
+ })
589
+
590
+ const resultPromise = browserTestGate({
591
+ mcpServers: [serverWithCmd],
592
+ taskConfig: { urls: ['http://localhost:3000'], visual_diff_threshold: 0.05 },
593
+ worktreePath: diffTmpDir,
594
+ })
595
+ vi.runAllTimersAsync()
596
+
597
+ const result = await resultPromise
598
+ expect(result.passed).toBe(true)
599
+ expect(result.output).toContain('No baseline found')
600
+ })
601
+
602
+ it('visual diff: fails when screenshot diff exceeds threshold', async () => {
603
+ const { spawn } = await import('node:child_process')
604
+ // Create baseline PNG at the path browserTestGate will compute
605
+ const baselinesDir = join(diffTmpDir, '.opencastle', 'baselines')
606
+ mkdirSync(baselinesDir, { recursive: true })
607
+ writeFileSync(
608
+ join(baselinesDir, 'http-localhost-3000.png'),
609
+ createTestPng(4, 4, [255, 0, 0, 255]),
610
+ )
611
+
612
+ let callCount = 0
613
+ vi.mocked(spawn).mockImplementation(() => {
614
+ callCount++
615
+ const child = makeFakeChild()
616
+ Promise.resolve().then(() => {
617
+ if (callCount === 1) child.stdout.emit('data', Buffer.from('200')) // curl
618
+ // call 2: browser automation — empty stdout (safe, exit 0)
619
+ // call 3: screenshot — empty stdout → Buffer of 0 bytes → pixelDiffPercentage returns 1.0
620
+ child.emit('close', 0)
621
+ })
622
+ return child as unknown as ReturnType<typeof spawn>
623
+ })
624
+
625
+ const resultPromise = browserTestGate({
626
+ mcpServers: [serverWithCmd],
627
+ taskConfig: { urls: ['http://localhost:3000'], visual_diff_threshold: 0.01 },
628
+ worktreePath: diffTmpDir,
629
+ })
630
+ vi.runAllTimersAsync()
631
+
632
+ const result = await resultPromise
633
+ expect(result.passed).toBe(false)
634
+ expect(result.output).toContain('FAIL')
635
+ })
636
+ })
637
+
638
+ // ── A11y audit integration ─────────────────────────────────────────────────
639
+
640
+ describe('a11y audit integration', () => {
641
+ const serverWithCmd = { name: 'playwright', type: 'browser', command: 'npx', args: ['playwright'] }
642
+
643
+ it('a11y: passes when audit returns no violations above threshold', async () => {
644
+ const { spawn } = await import('node:child_process')
645
+ let callCount = 0
646
+ vi.mocked(spawn).mockImplementation(() => {
647
+ callCount++
648
+ const child = makeFakeChild()
649
+ Promise.resolve().then(() => {
650
+ if (callCount === 1) child.stdout.emit('data', Buffer.from('200')) // curl
651
+ // call 2: browser automation — empty stdout
652
+ else if (callCount === 3) child.stdout.emit('data', Buffer.from('[]')) // a11y: no findings
653
+ child.emit('close', 0)
654
+ })
655
+ return child as unknown as ReturnType<typeof spawn>
656
+ })
657
+
658
+ const resultPromise = browserTestGate({
659
+ mcpServers: [serverWithCmd],
660
+ taskConfig: { urls: ['http://localhost:3000'], a11y: true },
661
+ worktreePath: '/tmp',
662
+ })
663
+ vi.runAllTimersAsync()
664
+
665
+ const result = await resultPromise
666
+ expect(result.passed).toBe(true)
667
+ expect(result.output).toContain('PASS')
668
+ })
669
+
670
+ it('a11y: fails when audit returns serious violations', async () => {
671
+ const { spawn } = await import('node:child_process')
672
+ const findings = JSON.stringify([
673
+ { id: 'label', impact: 'serious', description: 'Form elements must have labels.', nodes: 2 },
674
+ ])
675
+ let callCount = 0
676
+ vi.mocked(spawn).mockImplementation(() => {
677
+ callCount++
678
+ const child = makeFakeChild()
679
+ Promise.resolve().then(() => {
680
+ if (callCount === 1) child.stdout.emit('data', Buffer.from('200')) // curl
681
+ // call 2: browser automation — empty stdout
682
+ else if (callCount === 3) child.stdout.emit('data', Buffer.from(findings)) // a11y findings
683
+ child.emit('close', 0)
684
+ })
685
+ return child as unknown as ReturnType<typeof spawn>
686
+ })
687
+
688
+ const resultPromise = browserTestGate({
689
+ mcpServers: [serverWithCmd],
690
+ taskConfig: { urls: ['http://localhost:3000'], a11y: true, severity_threshold: 'serious' },
691
+ worktreePath: '/tmp',
692
+ })
693
+ vi.runAllTimersAsync()
694
+
695
+ const result = await resultPromise
696
+ expect(result.passed).toBe(false)
697
+ expect(result.output).toContain('FAIL')
698
+ })
699
+ })
700
+
701
+ // ── E2E integration ───────────────────────────────────────────────────────
702
+
703
+ describe('E2E integration', () => {
704
+ let e2eTmpDir: string
705
+
706
+ beforeEach(() => {
707
+ e2eTmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'browser-e2e-')))
708
+ })
709
+
710
+ afterEach(() => {
711
+ rmSync(e2eTmpDir, { recursive: true, force: true })
712
+ })
713
+
714
+ const playwrightServer = { name: 'playwright', type: 'browser', command: 'npx', args: ['playwright'] }
715
+
716
+ it('full pipeline passes: curl + browser automation + visual diff + a11y', async () => {
717
+ const { spawn } = await import('node:child_process')
718
+ // No baseline file — visual diff step runs but returns "No baseline found" (passed:true)
719
+
720
+ let callCount = 0
721
+ vi.mocked(spawn).mockImplementation(() => {
722
+ const currentCall = ++callCount
723
+ const child = makeFakeChild()
724
+ Promise.resolve().then(() => {
725
+ if (currentCall === 1) {
726
+ // curl → HTTP 200
727
+ child.stdout.emit('data', Buffer.from('200'))
728
+ } else if (currentCall === 2) {
729
+ // browser automation → ok
730
+ child.stdout.emit('data', Buffer.from('ok'))
731
+ } else if (currentCall === 3) {
732
+ // screenshot → empty (no baseline exists so diff is skipped)
733
+ child.stdout.emit('data', Buffer.from(''))
734
+ } else if (currentCall === 4) {
735
+ // a11y → no findings
736
+ child.stdout.emit('data', Buffer.from('[]'))
737
+ }
738
+ child.emit('close', 0)
739
+ })
740
+ return child as unknown as ReturnType<typeof spawn>
741
+ })
742
+
743
+ const resultPromise = browserTestGate({
744
+ mcpServers: [playwrightServer],
745
+ taskConfig: {
746
+ urls: ['http://localhost:3000'],
747
+ visual_diff_threshold: 0.01,
748
+ a11y: true,
749
+ severity_threshold: 'serious',
750
+ },
751
+ worktreePath: e2eTmpDir,
752
+ })
753
+ vi.runAllTimersAsync()
754
+
755
+ const result = await resultPromise
756
+ expect(result.passed).toBe(true)
757
+ expect(result.output).toContain('PASS')
758
+ expect(result.output).toContain('No baseline found')
759
+ expect(callCount).toBe(4)
760
+ })
761
+
762
+ it('full pipeline FAIL: visual diff exceeds threshold, a11y passes', async () => {
763
+ const { spawn } = await import('node:child_process')
764
+ const baselinesDir = join(e2eTmpDir, '.opencastle', 'baselines')
765
+ mkdirSync(baselinesDir, { recursive: true })
766
+ const baselinePng = createTestPng(4, 4, [255, 0, 0, 255])
767
+ writeFileSync(join(baselinesDir, 'http-localhost-3000.png'), baselinePng)
768
+ // Screenshot significantly differs from baseline
769
+ const differentPng = createTestPng(4, 4, [0, 0, 255, 255])
770
+
771
+ let callCount = 0
772
+ vi.mocked(spawn).mockImplementation(() => {
773
+ const currentCall = ++callCount
774
+ const child = makeFakeChild()
775
+ Promise.resolve().then(() => {
776
+ if (currentCall === 1) {
777
+ child.stdout.emit('data', Buffer.from('200'))
778
+ } else if (currentCall === 2) {
779
+ child.stdout.emit('data', Buffer.from('ok'))
780
+ } else if (currentCall === 3) {
781
+ // different screenshot → visual diff fails
782
+ child.stdout.emit('data', differentPng)
783
+ } else if (currentCall === 4) {
784
+ // a11y passes
785
+ child.stdout.emit('data', Buffer.from('[]'))
786
+ }
787
+ child.emit('close', 0)
788
+ })
789
+ return child as unknown as ReturnType<typeof spawn>
790
+ })
791
+
792
+ const resultPromise = browserTestGate({
793
+ mcpServers: [playwrightServer],
794
+ taskConfig: {
795
+ urls: ['http://localhost:3000'],
796
+ visual_diff_threshold: 0.01,
797
+ a11y: true,
798
+ severity_threshold: 'serious',
799
+ },
800
+ worktreePath: e2eTmpDir,
801
+ })
802
+ vi.runAllTimersAsync()
803
+
804
+ const result = await resultPromise
805
+ expect(result.passed).toBe(false)
806
+ expect(result.output).toContain('FAIL')
807
+ expect(result.output).toContain('Visual diff')
808
+ })
809
+
810
+ it('full pipeline FAIL: visual diff passes, a11y violations above severity threshold', async () => {
811
+ const { spawn } = await import('node:child_process')
812
+ // No baseline → visual diff step skipped (passed:true); only a11y fails
813
+ const a11yFindings = JSON.stringify([
814
+ { id: 'label', impact: 'critical', description: 'No labels on form inputs', nodes: 1 },
815
+ ])
816
+
817
+ let callCount = 0
818
+ vi.mocked(spawn).mockImplementation(() => {
819
+ const currentCall = ++callCount
820
+ const child = makeFakeChild()
821
+ Promise.resolve().then(() => {
822
+ if (currentCall === 1) {
823
+ child.stdout.emit('data', Buffer.from('200'))
824
+ } else if (currentCall === 2) {
825
+ child.stdout.emit('data', Buffer.from('ok'))
826
+ } else if (currentCall === 3) {
827
+ // screenshot → empty, no baseline file exists, visual diff skipped
828
+ child.stdout.emit('data', Buffer.from(''))
829
+ } else if (currentCall === 4) {
830
+ // a11y → critical finding, fails at 'serious' threshold
831
+ child.stdout.emit('data', Buffer.from(a11yFindings))
832
+ }
833
+ child.emit('close', 0)
834
+ })
835
+ return child as unknown as ReturnType<typeof spawn>
836
+ })
837
+
838
+ const resultPromise = browserTestGate({
839
+ mcpServers: [playwrightServer],
840
+ taskConfig: {
841
+ urls: ['http://localhost:3000'],
842
+ visual_diff_threshold: 0.01,
843
+ a11y: true,
844
+ severity_threshold: 'serious',
845
+ },
846
+ worktreePath: e2eTmpDir,
847
+ })
848
+ vi.runAllTimersAsync()
849
+
850
+ const result = await resultPromise
851
+ expect(result.passed).toBe(false)
852
+ expect(result.output).toContain('FAIL')
853
+ expect(result.output).toContain('A11y')
854
+ })
855
+
856
+ it('runA11yAudit standalone: passes when no violations returned', async () => {
857
+ const { spawn } = await import('node:child_process')
858
+ const mockChild = makeFakeChild()
859
+ vi.mocked(spawn).mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>)
860
+
861
+ const resultPromise = runA11yAudit({
862
+ mcpServers: [{ name: 'browser-tool', type: 'browser', command: 'audit-cmd' }],
863
+ url: 'http://localhost:3000',
864
+ severityThreshold: 'serious',
865
+ })
866
+ mockChild.stdout.emit('data', Buffer.from('[]'))
867
+ mockChild.emit('close', 0)
868
+ vi.runAllTimersAsync()
869
+
870
+ const result = await resultPromise
871
+ expect(result.passed).toBe(true)
872
+ expect(result.output).toContain('PASS')
873
+ })
874
+
875
+ it('runA11yAudit rejects external URLs (SSRF prevention)', async () => {
876
+ const result = await runA11yAudit({
877
+ mcpServers: [{ name: 'browser-tool', type: 'browser', command: 'audit-cmd' }],
878
+ url: 'https://evil.com',
879
+ severityThreshold: 'serious',
880
+ })
881
+ expect(result.passed).toBe(false)
882
+ expect(result.output).toContain('not a local address')
883
+ })
884
+
885
+ it('captureAndPersistBaseline → computeVisualDiff round-trip: matching buffers pass', async () => {
886
+ const buf = createTestPng(8, 8, [128, 128, 128, 255])
887
+ const baselineResult = captureAndPersistBaseline(buf, 'test-roundtrip', e2eTmpDir)
888
+ expect(baselineResult.persisted).toBe(true)
889
+
890
+ const baselinePath = join(e2eTmpDir, 'test-roundtrip.png')
891
+ expect(existsSync(baselinePath)).toBe(true)
892
+
893
+ const diffResult = await computeVisualDiff({
894
+ screenshotBuffer: buf,
895
+ baselinePath,
896
+ threshold: 0.01,
897
+ })
898
+ expect(diffResult.passed).toBe(true)
899
+ expect(diffResult.diffPercent).toBe(0)
900
+ })
901
+ })
902
+ })
903
+
904
+ // ── PNG test helper ───────────────────────────────────────────────────────────
905
+
906
+ /**
907
+ * Build a minimal valid PNG buffer with uniform RGBA pixels.
908
+ * Uses filter_type=0 (None) per row so decompressed data is directly usable.
909
+ * CRC fields are zeroed — our parser does not validate them.
910
+ */
911
+ function createTestPng(
912
+ width: number,
913
+ height: number,
914
+ rgba: [number, number, number, number],
915
+ ): Buffer {
916
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
917
+
918
+ // IHDR
919
+ const ihdrData = Buffer.alloc(13)
920
+ ihdrData.writeUInt32BE(width, 0)
921
+ ihdrData.writeUInt32BE(height, 4)
922
+ ihdrData[8] = 8 // bit depth
923
+ ihdrData[9] = 6 // color type: RGBA
924
+ const ihdrLen = Buffer.alloc(4)
925
+ ihdrLen.writeUInt32BE(13, 0)
926
+ const ihdr = Buffer.concat([ihdrLen, Buffer.from('IHDR'), ihdrData, Buffer.alloc(4)])
927
+
928
+ // Raw pixel data: filter_byte(0) + RGBA per row
929
+ const rowBytes = 1 + width * 4
930
+ const rawData = Buffer.alloc(height * rowBytes)
931
+ for (let row = 0; row < height; row++) {
932
+ rawData[row * rowBytes] = 0
933
+ for (let col = 0; col < width; col++) {
934
+ const off = row * rowBytes + 1 + col * 4
935
+ rawData[off] = rgba[0]
936
+ rawData[off + 1] = rgba[1]
937
+ rawData[off + 2] = rgba[2]
938
+ rawData[off + 3] = rgba[3]
939
+ }
940
+ }
941
+ const compressed = deflateSync(rawData)
942
+ const idatLen = Buffer.alloc(4)
943
+ idatLen.writeUInt32BE(compressed.length, 0)
944
+ const idat = Buffer.concat([idatLen, Buffer.from('IDAT'), compressed, Buffer.alloc(4)])
945
+
946
+ // IEND
947
+ const iend = Buffer.concat([Buffer.alloc(4), Buffer.from('IEND'), Buffer.alloc(4)])
948
+
949
+ return Buffer.concat([sig, ihdr, idat, iend])
950
+ }
951
+
952
+ // ── pixelDiffPercentage ───────────────────────────────────────────────────────
953
+
954
+ describe('pixelDiffPercentage', () => {
955
+ it('returns 0 for identical buffers', () => {
956
+ const buf = createTestPng(4, 4, [100, 150, 200, 255])
957
+ expect(pixelDiffPercentage(buf, buf)).toBe(0)
958
+ })
959
+
960
+ it('returns 1.0 for completely different pixel content', () => {
961
+ const red = createTestPng(4, 4, [255, 0, 0, 255])
962
+ const blue = createTestPng(4, 4, [0, 0, 255, 255])
963
+ const diff = pixelDiffPercentage(red, blue)
964
+ // All pixels differ by more than tolerance
965
+ expect(diff).toBeGreaterThan(0.9)
966
+ })
967
+
968
+ it('returns 1.0 when dimensions differ', () => {
969
+ const small = createTestPng(2, 2, [100, 100, 100, 255])
970
+ const large = createTestPng(4, 4, [100, 100, 100, 255])
971
+ expect(pixelDiffPercentage(small, large)).toBe(1.0)
972
+ })
973
+
974
+ it('returns a value between 0 and 1 for partially different buffers', () => {
975
+ const width = 4
976
+ const height = 4
977
+ const base = createTestPng(width, height, [100, 100, 100, 255])
978
+ // Build a PNG where half the pixels differ significantly
979
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
980
+ const ihdrData = Buffer.alloc(13)
981
+ ihdrData.writeUInt32BE(width, 0)
982
+ ihdrData.writeUInt32BE(height, 4)
983
+ ihdrData[8] = 8
984
+ ihdrData[9] = 6
985
+ const ihdrLen = Buffer.alloc(4)
986
+ ihdrLen.writeUInt32BE(13, 0)
987
+ const ihdr = Buffer.concat([ihdrLen, Buffer.from('IHDR'), ihdrData, Buffer.alloc(4)])
988
+ const rowBytes = 1 + width * 4
989
+ const rawData = Buffer.alloc(height * rowBytes)
990
+ for (let row = 0; row < height; row++) {
991
+ rawData[row * rowBytes] = 0
992
+ for (let col = 0; col < width; col++) {
993
+ const off = row * rowBytes + 1 + col * 4
994
+ // First half of pixels: same as base; second half: very different
995
+ const isDifferent = row * width + col >= (width * height) / 2
996
+ rawData[off] = isDifferent ? 255 : 100
997
+ rawData[off + 1] = isDifferent ? 0 : 100
998
+ rawData[off + 2] = isDifferent ? 0 : 100
999
+ rawData[off + 3] = 255
1000
+ }
1001
+ }
1002
+ const compressed = deflateSync(rawData)
1003
+ const idatLen = Buffer.alloc(4)
1004
+ idatLen.writeUInt32BE(compressed.length, 0)
1005
+ const idat = Buffer.concat([idatLen, Buffer.from('IDAT'), compressed, Buffer.alloc(4)])
1006
+ const iend = Buffer.concat([Buffer.alloc(4), Buffer.from('IEND'), Buffer.alloc(4)])
1007
+ const modified = Buffer.concat([sig, ihdr, idat, iend])
1008
+
1009
+ const diff = pixelDiffPercentage(base, modified)
1010
+ expect(diff).toBeGreaterThan(0)
1011
+ expect(diff).toBeLessThan(1)
1012
+ })
1013
+
1014
+ it('handles empty/invalid buffers gracefully and returns 1.0', () => {
1015
+ expect(pixelDiffPercentage(Buffer.from([]), Buffer.from([]))).toBe(1.0)
1016
+ expect(pixelDiffPercentage(Buffer.from('not a png'), Buffer.from('not a png'))).toBe(1.0)
1017
+ const valid = createTestPng(2, 2, [0, 0, 0, 255])
1018
+ expect(pixelDiffPercentage(valid, Buffer.from([]))).toBe(1.0)
1019
+ })
1020
+ })
1021
+
1022
+ // ── computeVisualDiff ─────────────────────────────────────────────────────────
1023
+
1024
+ describe('computeVisualDiff', () => {
1025
+ let tmpDir: string
1026
+
1027
+ beforeEach(() => {
1028
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'visual-diff-test-')))
1029
+ })
1030
+
1031
+ afterEach(() => {
1032
+ rmSync(tmpDir, { recursive: true, force: true })
1033
+ })
1034
+
1035
+ it('returns passed:true with "No baseline found" when baseline does not exist', async () => {
1036
+ const buf = createTestPng(2, 2, [255, 0, 0, 255])
1037
+ const result = await computeVisualDiff({
1038
+ screenshotBuffer: buf,
1039
+ baselinePath: join(tmpDir, 'nonexistent.png'),
1040
+ threshold: 0.05,
1041
+ })
1042
+ expect(result.passed).toBe(true)
1043
+ expect(result.diffPercent).toBe(0)
1044
+ expect(result.output).toContain('No baseline found')
1045
+ })
1046
+
1047
+ it('returns passed:true when diff is under threshold', async () => {
1048
+ const buf = createTestPng(4, 4, [100, 100, 100, 255])
1049
+ const baselinePath = join(tmpDir, 'base.png')
1050
+ writeFileSync(baselinePath, buf)
1051
+ const result = await computeVisualDiff({
1052
+ screenshotBuffer: buf,
1053
+ baselinePath,
1054
+ threshold: 0.05,
1055
+ })
1056
+ expect(result.passed).toBe(true)
1057
+ expect(result.diffPercent).toBe(0)
1058
+ expect(result.output).toContain('PASS')
1059
+ })
1060
+
1061
+ it('returns passed:false when diff exceeds threshold', async () => {
1062
+ const baseline = createTestPng(4, 4, [100, 100, 100, 255])
1063
+ const screenshot = createTestPng(4, 4, [255, 0, 0, 255])
1064
+ const baselinePath = join(tmpDir, 'base.png')
1065
+ writeFileSync(baselinePath, baseline)
1066
+ const result = await computeVisualDiff({
1067
+ screenshotBuffer: screenshot,
1068
+ baselinePath,
1069
+ threshold: 0.01,
1070
+ })
1071
+ expect(result.passed).toBe(false)
1072
+ expect(result.diffPercent).toBeGreaterThan(0.01)
1073
+ expect(result.output).toContain('FAIL')
1074
+ })
1075
+ })
1076
+
1077
+ // ── mapA11ySeverity ───────────────────────────────────────────────────────────
1078
+
1079
+ describe('mapA11ySeverity', () => {
1080
+ const findings: A11yFinding[] = [
1081
+ { id: 'color-contrast', impact: 'serious', description: 'Elements must have sufficient color contrast.', nodes: 3 },
1082
+ { id: 'label', impact: 'critical', description: 'Form elements must have labels.', nodes: 1 },
1083
+ { id: 'list', impact: 'moderate', description: 'List must not be empty.', nodes: 2 },
1084
+ { id: 'alt-text', impact: 'minor', description: 'Images must have alternate text.', nodes: 5 },
1085
+ ]
1086
+
1087
+ it('returns passed:true when no findings exceed threshold', () => {
1088
+ const result = mapA11ySeverity([], 'serious')
1089
+ expect(result.passed).toBe(true)
1090
+ expect(result.findings).toHaveLength(0)
1091
+ expect(result.output).toContain('PASS')
1092
+ })
1093
+
1094
+ it('returns passed:false when findings exceed threshold', () => {
1095
+ const result = mapA11ySeverity(findings, 'serious')
1096
+ expect(result.passed).toBe(false)
1097
+ expect(result.output).toContain('FAIL')
1098
+ })
1099
+
1100
+ it('correctly filters by severity level', () => {
1101
+ // threshold=critical: only critical findings should fail the check
1102
+ const critical = mapA11ySeverity(findings, 'critical')
1103
+ expect(critical.findings.every((f) => f.impact === 'critical')).toBe(true)
1104
+ // threshold=minor: all findings should be in failing list
1105
+ const minor = mapA11ySeverity(findings, 'minor')
1106
+ expect(minor.findings).toHaveLength(4)
1107
+ // threshold=moderate: critical, serious, moderate
1108
+ const moderate = mapA11ySeverity(findings, 'moderate')
1109
+ expect(moderate.findings.every((f) =>
1110
+ ['critical', 'serious', 'moderate'].includes(f.impact),
1111
+ )).toBe(true)
1112
+ })
1113
+
1114
+ it('includes finding descriptions in output (truncated to 200 chars)', () => {
1115
+ const longDesc: A11yFinding = {
1116
+ id: 'long-rule',
1117
+ impact: 'serious',
1118
+ description: 'A'.repeat(300),
1119
+ nodes: 1,
1120
+ }
1121
+ const result = mapA11ySeverity([longDesc], 'serious')
1122
+ expect(result.passed).toBe(false)
1123
+ const lines = result.output.split('\n')
1124
+ const descLine = lines.find((l) => l.includes('long-rule'))
1125
+ expect(descLine).toBeDefined()
1126
+ // Description should be sliced to 200 chars max in the output line
1127
+ expect(descLine!.length).toBeLessThan(300)
1128
+ })
1129
+ })
1130
+
1131
+ // ── captureAndPersistBaseline ─────────────────────────────────────────────────
1132
+
1133
+ describe('captureAndPersistBaseline', () => {
1134
+ let tmpDir: string
1135
+
1136
+ beforeEach(() => {
1137
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'baselines-test-')))
1138
+ vi.clearAllMocks()
1139
+ })
1140
+
1141
+ afterEach(() => {
1142
+ rmSync(tmpDir, { recursive: true, force: true })
1143
+ })
1144
+
1145
+ it('persists a PNG buffer to disk', () => {
1146
+ const buf = createTestPng(2, 2, [255, 0, 0, 255])
1147
+ const result = captureAndPersistBaseline(buf, 'test-red', tmpDir)
1148
+ expect(result.persisted).toBe(true)
1149
+ expect(existsSync(join(tmpDir, 'test-red.png'))).toBe(true)
1150
+ expect(readFileSync(join(tmpDir, 'test-red.png'))).toEqual(buf)
1151
+ })
1152
+
1153
+ it('creates directory if it does not exist', () => {
1154
+ const newDir = join(tmpDir, 'nested', 'baselines')
1155
+ const buf = createTestPng(1, 1, [0, 255, 0, 255])
1156
+ const result = captureAndPersistBaseline(buf, 'green', newDir)
1157
+ expect(result.persisted).toBe(true)
1158
+ expect(existsSync(join(newDir, 'green.png'))).toBe(true)
1159
+ })
1160
+
1161
+ it('uses slug as the filename without extension in the directory', () => {
1162
+ const buf = createTestPng(1, 1, [0, 0, 255, 255])
1163
+ const result = captureAndPersistBaseline(buf, 'my-page-home', tmpDir)
1164
+ expect(result.persisted).toBe(true)
1165
+ expect(existsSync(join(tmpDir, 'my-page-home.png'))).toBe(true)
1166
+ expect(existsSync(join(tmpDir, 'my-page-home'))).toBe(false)
1167
+ })
1168
+ })
1169
+