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.
- package/README.md +7 -1
- package/bin/cli.mjs +10 -0
- package/dist/cli/agents.d.ts +3 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +161 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/baselines.d.ts +3 -0
- package/dist/cli/baselines.d.ts.map +1 -0
- package/dist/cli/baselines.js +128 -0
- package/dist/cli/baselines.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +68 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2102 -26
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1572 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/events.d.ts +4 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +74 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +154 -27
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/expertise.d.ts +16 -0
- package/dist/cli/convoy/expertise.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.js +121 -0
- package/dist/cli/convoy/expertise.js.map +1 -0
- package/dist/cli/convoy/expertise.test.d.ts +2 -0
- package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.test.js +96 -0
- package/dist/cli/convoy/expertise.test.js.map +1 -0
- package/dist/cli/convoy/export.test.js +1 -0
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/formula.d.ts +19 -0
- package/dist/cli/convoy/formula.d.ts.map +1 -0
- package/dist/cli/convoy/formula.js +142 -0
- package/dist/cli/convoy/formula.js.map +1 -0
- package/dist/cli/convoy/formula.test.d.ts +2 -0
- package/dist/cli/convoy/formula.test.d.ts.map +1 -0
- package/dist/cli/convoy/formula.test.js +342 -0
- package/dist/cli/convoy/formula.test.js.map +1 -0
- package/dist/cli/convoy/gates.d.ts +128 -0
- package/dist/cli/convoy/gates.d.ts.map +1 -0
- package/dist/cli/convoy/gates.js +606 -0
- package/dist/cli/convoy/gates.js.map +1 -0
- package/dist/cli/convoy/gates.test.d.ts +2 -0
- package/dist/cli/convoy/gates.test.d.ts.map +1 -0
- package/dist/cli/convoy/gates.test.js +976 -0
- package/dist/cli/convoy/gates.test.js.map +1 -0
- package/dist/cli/convoy/health.d.ts +11 -0
- package/dist/cli/convoy/health.d.ts.map +1 -1
- package/dist/cli/convoy/health.js +54 -0
- package/dist/cli/convoy/health.js.map +1 -1
- package/dist/cli/convoy/health.test.js +56 -1
- package/dist/cli/convoy/health.test.js.map +1 -1
- package/dist/cli/convoy/issues.d.ts +8 -0
- package/dist/cli/convoy/issues.d.ts.map +1 -0
- package/dist/cli/convoy/issues.js +98 -0
- package/dist/cli/convoy/issues.js.map +1 -0
- package/dist/cli/convoy/issues.test.d.ts +2 -0
- package/dist/cli/convoy/issues.test.d.ts.map +1 -0
- package/dist/cli/convoy/issues.test.js +107 -0
- package/dist/cli/convoy/issues.test.js.map +1 -0
- package/dist/cli/convoy/knowledge.d.ts +5 -0
- package/dist/cli/convoy/knowledge.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.js +116 -0
- package/dist/cli/convoy/knowledge.js.map +1 -0
- package/dist/cli/convoy/knowledge.test.d.ts +2 -0
- package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.test.js +87 -0
- package/dist/cli/convoy/knowledge.test.js.map +1 -0
- package/dist/cli/convoy/lessons.d.ts +17 -0
- package/dist/cli/convoy/lessons.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.js +149 -0
- package/dist/cli/convoy/lessons.js.map +1 -0
- package/dist/cli/convoy/lessons.test.d.ts +2 -0
- package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.test.js +135 -0
- package/dist/cli/convoy/lessons.test.js.map +1 -0
- package/dist/cli/convoy/lock.d.ts +13 -0
- package/dist/cli/convoy/lock.d.ts.map +1 -0
- package/dist/cli/convoy/lock.js +88 -0
- package/dist/cli/convoy/lock.js.map +1 -0
- package/dist/cli/convoy/lock.test.d.ts +2 -0
- package/dist/cli/convoy/lock.test.d.ts.map +1 -0
- package/dist/cli/convoy/lock.test.js +136 -0
- package/dist/cli/convoy/lock.test.js.map +1 -0
- package/dist/cli/convoy/merge.d.ts +4 -0
- package/dist/cli/convoy/merge.d.ts.map +1 -1
- package/dist/cli/convoy/merge.js +18 -1
- package/dist/cli/convoy/merge.js.map +1 -1
- package/dist/cli/convoy/merge.test.js +6 -7
- package/dist/cli/convoy/merge.test.js.map +1 -1
- package/dist/cli/convoy/partition.d.ts +51 -0
- package/dist/cli/convoy/partition.d.ts.map +1 -0
- package/dist/cli/convoy/partition.js +186 -0
- package/dist/cli/convoy/partition.js.map +1 -0
- package/dist/cli/convoy/partition.test.d.ts +2 -0
- package/dist/cli/convoy/partition.test.d.ts.map +1 -0
- package/dist/cli/convoy/partition.test.js +315 -0
- package/dist/cli/convoy/partition.test.js.map +1 -0
- package/dist/cli/convoy/pipeline.test.js +6 -0
- package/dist/cli/convoy/pipeline.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +47 -5
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +525 -19
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1345 -12
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +156 -2
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/destroy.d.ts +3 -0
- package/dist/cli/destroy.d.ts.map +1 -0
- package/dist/cli/destroy.js +69 -0
- package/dist/cli/destroy.js.map +1 -0
- package/dist/cli/destroy.test.d.ts +2 -0
- package/dist/cli/destroy.test.d.ts.map +1 -0
- package/dist/cli/destroy.test.js +116 -0
- package/dist/cli/destroy.test.js.map +1 -0
- package/dist/cli/gitignore.d.ts +9 -0
- package/dist/cli/gitignore.d.ts.map +1 -1
- package/dist/cli/gitignore.js +29 -0
- package/dist/cli/gitignore.js.map +1 -1
- package/dist/cli/plan.d.ts +3 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli/plan.js +288 -0
- package/dist/cli/plan.js.map +1 -0
- package/dist/cli/run/adapters/claude.d.ts +2 -0
- package/dist/cli/run/adapters/claude.d.ts.map +1 -1
- package/dist/cli/run/adapters/claude.js +89 -49
- package/dist/cli/run/adapters/claude.js.map +1 -1
- package/dist/cli/run/adapters/claude.test.d.ts +2 -0
- package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude.test.js +205 -0
- package/dist/cli/run/adapters/claude.test.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +1 -0
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +84 -46
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
- package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/copilot.test.js +195 -0
- package/dist/cli/run/adapters/copilot.test.js.map +1 -0
- package/dist/cli/run/adapters/cursor.d.ts +1 -0
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/run/adapters/cursor.js +83 -47
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
- package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/cursor.test.js +129 -0
- package/dist/cli/run/adapters/cursor.test.js.map +1 -0
- package/dist/cli/run/adapters/opencode.d.ts +1 -0
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/run/adapters/opencode.js +81 -47
- package/dist/cli/run/adapters/opencode.js.map +1 -1
- package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
- package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/opencode.test.js +119 -0
- package/dist/cli/run/adapters/opencode.test.js.map +1 -0
- package/dist/cli/run/executor.js +1 -1
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +245 -4
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +669 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +362 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +85 -2
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js.map +1 -1
- package/dist/cli/watch.d.ts +15 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +279 -0
- package/dist/cli/watch.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/engine.test.ts +1839 -70
- package/src/cli/convoy/engine.ts +2417 -38
- package/src/cli/convoy/events.test.ts +179 -38
- package/src/cli/convoy/events.ts +88 -16
- package/src/cli/convoy/expertise.test.ts +128 -0
- package/src/cli/convoy/expertise.ts +163 -0
- package/src/cli/convoy/export.test.ts +1 -0
- package/src/cli/convoy/formula.test.ts +405 -0
- package/src/cli/convoy/formula.ts +174 -0
- package/src/cli/convoy/gates.test.ts +1169 -0
- package/src/cli/convoy/gates.ts +774 -0
- package/src/cli/convoy/health.test.ts +64 -2
- package/src/cli/convoy/health.ts +80 -2
- package/src/cli/convoy/issues.test.ts +143 -0
- package/src/cli/convoy/issues.ts +136 -0
- package/src/cli/convoy/knowledge.test.ts +101 -0
- package/src/cli/convoy/knowledge.ts +132 -0
- package/src/cli/convoy/lessons.test.ts +188 -0
- package/src/cli/convoy/lessons.ts +164 -0
- package/src/cli/convoy/lock.test.ts +181 -0
- package/src/cli/convoy/lock.ts +103 -0
- package/src/cli/convoy/merge.test.ts +6 -7
- package/src/cli/convoy/merge.ts +19 -1
- package/src/cli/convoy/partition.test.ts +423 -0
- package/src/cli/convoy/partition.ts +232 -0
- package/src/cli/convoy/pipeline.test.ts +6 -0
- package/src/cli/convoy/store.test.ts +1512 -14
- package/src/cli/convoy/store.ts +676 -30
- package/src/cli/convoy/types.ts +170 -1
- package/src/cli/destroy.test.ts +141 -0
- package/src/cli/destroy.ts +88 -0
- package/src/cli/gitignore.ts +36 -0
- package/src/cli/plan.ts +316 -0
- package/src/cli/run/adapters/claude.test.ts +234 -0
- package/src/cli/run/adapters/claude.ts +45 -5
- package/src/cli/run/adapters/copilot.test.ts +224 -0
- package/src/cli/run/adapters/copilot.ts +34 -4
- package/src/cli/run/adapters/cursor.test.ts +144 -0
- package/src/cli/run/adapters/cursor.ts +33 -2
- package/src/cli/run/adapters/opencode.test.ts +135 -0
- package/src/cli/run/adapters/opencode.ts +30 -2
- package/src/cli/run/executor.ts +1 -1
- package/src/cli/run/schema.test.ts +758 -0
- package/src/cli/run/schema.ts +300 -25
- package/src/cli/run.ts +341 -21
- package/src/cli/types.ts +86 -1
- package/src/cli/watch.ts +298 -0
- 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
|
+
|