tackbox 0.1.0 → 0.1.63
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 +22 -22
- package/package.json +7 -3
- package/js/tests/eslint-wrapper.test.js +0 -53
- package/js/tests/mdlint-wrapper.test.js +0 -85
- package/js/tests/no-non-ascii.test.js +0 -124
- package/js/tests/report.test.js +0 -182
- package/js/tests/reporters.test.js +0 -223
- package/js/tests/rules.test.js +0 -650
- package/js/tests/svelte.test.js +0 -61
package/README.md
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
**Every failure must report, propagate, or explain itself.**
|
|
10
10
|
|
|
11
|
+
tackbox is a guardrail for developers and coding agents. It catches common
|
|
12
|
+
local ways failures are hidden by accident, haste, or expediency and
|
|
13
|
+
requires an explicit outcome: propagate, report, or explain. It is not a
|
|
14
|
+
whole-program proof or a security boundary.
|
|
15
|
+
|
|
16
|
+
tackbox's lint contract recognizes direct reporting helpers as an explicit
|
|
17
|
+
report outcome. Execution policy, control flow, result ownership, and runtime
|
|
18
|
+
integration remain application decisions.
|
|
19
|
+
|
|
11
20
|
Coding agents write error handling that looks right and silently
|
|
12
21
|
isn't: a swallowed exception, a fatal exit with nothing logged, a
|
|
13
22
|
report with the cause stripped out. tackbox catches it the moment
|
|
@@ -15,9 +24,9 @@ it's written: hooked into the agent's edit loop it flags the finding
|
|
|
15
24
|
before the turn ends, and the same rules gate pre-commit and CI -
|
|
16
25
|
one coverage bar for hand-written and agent-written code.
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
There are no per-rule disable flags. A local exception stays visible as
|
|
28
|
+
a reasoned `// no-report: <reason>` marker at the site, and the agent
|
|
29
|
+
hook asks for your approval before a new suppression lands.
|
|
21
30
|
|
|
22
31
|
```go
|
|
23
32
|
resp, err := client.Do(req)
|
|
@@ -174,7 +183,7 @@ fetched separately and cached per version:
|
|
|
174
183
|
|
|
175
184
|
After the first fetch tackbox runs fully offline until the engine
|
|
176
185
|
version changes. Platform wheels cover Linux x86_64/arm64 (manylinux),
|
|
177
|
-
macOS
|
|
186
|
+
macOS arm64, and Windows x86_64. `engines.json` in the thin
|
|
178
187
|
wheel records the source, version, sha256, and license of every
|
|
179
188
|
bundled binary and dependency; `tackbox doctor` fetches the store if
|
|
180
189
|
absent and verifies the payload against it.
|
|
@@ -187,9 +196,8 @@ test-skip rules (via the `pyrules` flake8 plugin), frontend swallow,
|
|
|
187
196
|
notify, and test-skip rules (JS, TS, Svelte, via ESLint), and Markdown
|
|
188
197
|
(MD001-060 + ASCII).
|
|
189
198
|
|
|
190
|
-
See `go/README.md` for the Go ruleset.
|
|
191
|
-
|
|
192
|
-
outside this repo (private notes); the public summary:
|
|
199
|
+
See `go/README.md` for the complete Go ruleset. Across supported
|
|
200
|
+
languages, the core policy is:
|
|
193
201
|
|
|
194
202
|
- Every `err != nil` branch must propagate, capture, or carry an
|
|
195
203
|
explicit `// no-report: <reason>` marker.
|
|
@@ -201,8 +209,9 @@ outside this repo (private notes); the public summary:
|
|
|
201
209
|
- Bare `return nil` from a single-result function must carry
|
|
202
210
|
`// nil-return: <reason>` or use `(val, ok)` / `(val, err)`.
|
|
203
211
|
- A single err-branch may not both capture and `return err`.
|
|
204
|
-
-
|
|
205
|
-
|
|
212
|
+
- The dedupKey must be a well-formed literal; in Go, capture-call
|
|
213
|
+
arguments must additionally not carry raw user input (a
|
|
214
|
+
`*http.Request` field).
|
|
206
215
|
- A `notify` (user lane only, no capture) may terminate a failure path
|
|
207
216
|
only when it is narrowed: a narrow catch type (Java/Python) or an
|
|
208
217
|
additional condition inside the branch (Go/JS). An unconditional
|
|
@@ -361,20 +370,11 @@ on a non-Go file is rejected - a dead line would be silent. The format
|
|
|
361
370
|
is language-uniform; the restriction lifts as other engines adopt the
|
|
362
371
|
contract.
|
|
363
372
|
|
|
364
|
-
##
|
|
365
|
-
|
|
366
|
-
Dedup lives at two levels with different owners
|
|
367
|
-
(`docs/report-contracts.md` D005):
|
|
373
|
+
## Runtime reporting helpers
|
|
368
374
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
- The user lane is never suppressed by the helpers. Every user-facing
|
|
373
|
-
event is delivered carrying its dedupKey; collapsing a storm into
|
|
374
|
-
one live banner or a counter is presentation policy and belongs to
|
|
375
|
-
the app's listener, keyed on that dedupKey. A notification dropped
|
|
376
|
-
inside the helper would be a swallowed error at the UI level - the
|
|
377
|
-
exact failure mode tackbox exists to prevent.
|
|
375
|
+
Direct reporting helpers ship per language; their shared runtime behavior -
|
|
376
|
+
lane routing, telemetry dedup, panic grouping, and capture isolation - is
|
|
377
|
+
specified in [docs/report-contracts.md](docs/report-contracts.md).
|
|
378
378
|
|
|
379
379
|
## Agent hook (Claude Code)
|
|
380
380
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tackbox",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ESLint
|
|
3
|
+
"version": "0.1.63",
|
|
4
|
+
"description": "ESLint and Markdown lint plugins plus direct error-reporting helpers for JavaScript and TypeScript.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./js/eslint-plugin.js",
|
|
7
7
|
"exports": {
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
"tackbox-mdlint": "./bin/tackbox-mdlint.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
-
"js/",
|
|
16
|
+
"js/eslint-plugin.js",
|
|
17
|
+
"js/report.js",
|
|
18
|
+
"js/rules/",
|
|
19
|
+
"js/markdownlint-rules/",
|
|
20
|
+
"js/README.md",
|
|
17
21
|
"bin/",
|
|
18
22
|
"eslint.config.preset.js"
|
|
19
23
|
],
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
const path = require('node:path')
|
|
2
|
-
const os = require('node:os')
|
|
3
|
-
const fs = require('node:fs')
|
|
4
|
-
const { spawnSync } = require('node:child_process')
|
|
5
|
-
const { test } = require('node:test')
|
|
6
|
-
const assert = require('node:assert/strict')
|
|
7
|
-
|
|
8
|
-
const WRAPPER = path.resolve(__dirname, '..', '..', 'bin', 'tackbox-eslint.js')
|
|
9
|
-
|
|
10
|
-
// An empty catch defeated by an inline eslint-disable. The hermetic wrapper must
|
|
11
|
-
// ignore the directive (allowInlineConfig: false) so the swallow still fails the
|
|
12
|
-
// run: an inline disable is an uninventoried, ungated bypass of every JS rule,
|
|
13
|
-
// invisible to `tackbox escapes` and the approval gate.
|
|
14
|
-
const DISABLED_SWALLOW = [
|
|
15
|
-
'function handler() {',
|
|
16
|
-
' try {',
|
|
17
|
-
' doThing()',
|
|
18
|
-
' // eslint-disable-next-line tackbox/no-swallow-catch',
|
|
19
|
-
' } catch (e) {',
|
|
20
|
-
' }',
|
|
21
|
-
'}',
|
|
22
|
-
'',
|
|
23
|
-
].join('\n')
|
|
24
|
-
|
|
25
|
-
function inTmpDir(body) {
|
|
26
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tackbox-eslint-'))
|
|
27
|
-
fs.writeFileSync(path.join(dir, 'bad.js'), DISABLED_SWALLOW)
|
|
28
|
-
try {
|
|
29
|
-
return body(dir)
|
|
30
|
-
} finally {
|
|
31
|
-
fs.rmSync(dir, { recursive: true, force: true })
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function runWrapper(dir, args) {
|
|
36
|
-
return spawnSync('node', [WRAPPER, ...args, 'bad.js'], { cwd: dir, encoding: 'utf8' })
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
test('inline eslint-disable cannot silence a swallow (default mode)', () => {
|
|
40
|
-
inTmpDir(dir => {
|
|
41
|
-
const r = runWrapper(dir, [])
|
|
42
|
-
assert.equal(r.status, 1, r.stdout + r.stderr)
|
|
43
|
-
assert.match(r.stdout, /no-swallow-catch/)
|
|
44
|
-
})
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('inline eslint-disable cannot silence a swallow (--machine mode)', () => {
|
|
48
|
-
inTmpDir(dir => {
|
|
49
|
-
const r = runWrapper(dir, ['--machine'])
|
|
50
|
-
assert.equal(r.status, 1, r.stdout + r.stderr)
|
|
51
|
-
assert.match(r.stdout, /no-swallow-catch/)
|
|
52
|
-
})
|
|
53
|
-
})
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
const { test } = require('node:test')
|
|
2
|
-
const assert = require('node:assert/strict')
|
|
3
|
-
const { spawnSync } = require('node:child_process')
|
|
4
|
-
const { mkdtempSync, writeFileSync, rmSync } = require('node:fs')
|
|
5
|
-
const { tmpdir } = require('node:os')
|
|
6
|
-
const path = require('node:path')
|
|
7
|
-
|
|
8
|
-
const WRAPPER = path.resolve(__dirname, '..', '..', 'bin', 'tackbox-mdlint.js')
|
|
9
|
-
|
|
10
|
-
function withTmp(fn) {
|
|
11
|
-
const dir = mkdtempSync(path.join(tmpdir(), 'tackbox-mdlint-'))
|
|
12
|
-
try { return fn(dir) } finally { rmSync(dir, { recursive: true, force: true }) }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function lintInTmp(dir, file) {
|
|
16
|
-
return spawnSync('node', [WRAPPER, file], { cwd: dir, encoding: 'utf8' })
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
test('ignores consumer .markdownlint.json that disables defaults', () => {
|
|
20
|
-
withTmp(dir => {
|
|
21
|
-
writeFileSync(path.join(dir, '.markdownlint.json'), '{"default": false}')
|
|
22
|
-
writeFileSync(path.join(dir, 'bad.md'), '# hi\n\nrocket: \u{1F680}\n')
|
|
23
|
-
const r = lintInTmp(dir, 'bad.md')
|
|
24
|
-
assert.equal(r.status, 1, r.stdout + r.stderr)
|
|
25
|
-
assert.match(r.stdout, /no-non-ascii/)
|
|
26
|
-
assert.match(r.stdout, /U\+1F680/)
|
|
27
|
-
})
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
test('rejects inline markdownlint-disable for the ASCII rule', () => {
|
|
31
|
-
withTmp(dir => {
|
|
32
|
-
const md = [
|
|
33
|
-
'# hi',
|
|
34
|
-
'',
|
|
35
|
-
'<!-- markdownlint-disable no-non-ascii -->',
|
|
36
|
-
'',
|
|
37
|
-
'still flagged: \u{2014}',
|
|
38
|
-
'',
|
|
39
|
-
].join('\n')
|
|
40
|
-
writeFileSync(path.join(dir, 'bad.md'), md)
|
|
41
|
-
const r = lintInTmp(dir, 'bad.md')
|
|
42
|
-
assert.equal(r.status, 1, r.stdout + r.stderr)
|
|
43
|
-
assert.match(r.stdout, /U\+2014/)
|
|
44
|
-
})
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('exits 0 on clean ASCII file', () => {
|
|
48
|
-
withTmp(dir => {
|
|
49
|
-
writeFileSync(path.join(dir, 'ok.md'), '# clean\n\nplain ASCII only.\n')
|
|
50
|
-
const r = lintInTmp(dir, 'ok.md')
|
|
51
|
-
assert.equal(r.status, 0, r.stdout + r.stderr)
|
|
52
|
-
assert.equal(r.stdout, '')
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
// End-to-end (default rules + noInlineConfig): the lang marker survives the
|
|
57
|
-
// real CLI path, not just the rule unit tests.
|
|
58
|
-
|
|
59
|
-
const PRIVET = '\u{41F}\u{440}\u{438}\u{432}\u{435}\u{442}' // "Privet" (hello)
|
|
60
|
-
const MIR = '\u{43C}\u{438}\u{440}' // "mir" (world)
|
|
61
|
-
|
|
62
|
-
test('ru marker: Russian prose passes the real wrapper clean', () => {
|
|
63
|
-
withTmp(dir => {
|
|
64
|
-
writeFileSync(
|
|
65
|
-
path.join(dir, 'ru.md'),
|
|
66
|
-
'<!-- tackbox: lang=ru personal repo -->\n\n# notes\n\n' + PRIVET + ' \u{2014} ' + MIR + '.\n'
|
|
67
|
-
)
|
|
68
|
-
const r = lintInTmp(dir, 'ru.md')
|
|
69
|
-
assert.equal(r.status, 0, r.stdout + r.stderr)
|
|
70
|
-
assert.equal(r.stdout, '')
|
|
71
|
-
})
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('ru marker still flags an emoji, not the Cyrillic', () => {
|
|
75
|
-
withTmp(dir => {
|
|
76
|
-
writeFileSync(
|
|
77
|
-
path.join(dir, 'ru.md'),
|
|
78
|
-
'<!-- tackbox: lang=ru note -->\n\n# notes\n\n' + PRIVET + ' \u{1F680}\n'
|
|
79
|
-
)
|
|
80
|
-
const r = lintInTmp(dir, 'ru.md')
|
|
81
|
-
assert.equal(r.status, 1, r.stdout + r.stderr)
|
|
82
|
-
assert.match(r.stdout, /U\+1F680/)
|
|
83
|
-
assert.doesNotMatch(r.stdout, /U\+41F/)
|
|
84
|
-
})
|
|
85
|
-
})
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
const { test } = require('node:test')
|
|
2
|
-
const assert = require('node:assert/strict')
|
|
3
|
-
const { lint } = require('markdownlint/promise')
|
|
4
|
-
const rule = require('../markdownlint-rules/no-non-ascii')
|
|
5
|
-
|
|
6
|
-
async function run(markdown) {
|
|
7
|
-
const out = await lint({
|
|
8
|
-
strings: { 'in.md': markdown },
|
|
9
|
-
config: { default: false, 'no-non-ascii': true },
|
|
10
|
-
customRules: [rule],
|
|
11
|
-
})
|
|
12
|
-
return out['in.md']
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
test('passes on pure ASCII content', async () => {
|
|
16
|
-
const errs = await run([
|
|
17
|
-
'# heading',
|
|
18
|
-
'',
|
|
19
|
-
'A paragraph with - dash, "quotes", `code`, and 0x7f end.',
|
|
20
|
-
'',
|
|
21
|
-
'```bash',
|
|
22
|
-
'echo hi',
|
|
23
|
-
'```',
|
|
24
|
-
].join('\n'))
|
|
25
|
-
assert.equal(errs.length, 0)
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
test('flags assorted non-ASCII (em-dash, curly, Cyrillic, box)', async () => {
|
|
29
|
-
const errs = await run('em \u{2014} curly \u{201C}x\u{201D} hi \u{43F}\n\u{251C}\n')
|
|
30
|
-
const hexes = errs.map(e => e.errorDetail.match(/U\+([0-9A-F]+)/)[1])
|
|
31
|
-
assert.deepEqual(hexes, ['2014', '201C', '201D', '43F', '251C'])
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
test('flags non-ASCII inside fenced code blocks', async () => {
|
|
35
|
-
const errs = await run('```text\nhello \u{2014} world\n```\n')
|
|
36
|
-
assert.equal(errs.length, 1)
|
|
37
|
-
assert.equal(errs[0].lineNumber, 2)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('reports column position and astral codepoints', async () => {
|
|
41
|
-
const errs = await run('abc\u{1F680}xyz\n')
|
|
42
|
-
assert.equal(errs.length, 1)
|
|
43
|
-
assert.match(errs[0].errorDetail, /U\+1F680/)
|
|
44
|
-
assert.deepEqual(errs[0].errorRange, [4, 2])
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
// -- lang marker ----------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
// Cyrillic building blocks, written as \u escapes so this source stays ASCII.
|
|
50
|
-
const PRIVET = '\u{41F}\u{440}\u{438}\u{432}\u{435}\u{442}' // "Privet" (hello)
|
|
51
|
-
const MIR = '\u{43C}\u{438}\u{440}' // "mir" (world)
|
|
52
|
-
|
|
53
|
-
function detailHexes(errs) {
|
|
54
|
-
return errs.map(e => e.errorDetail.match(/U\+([0-9A-F]+)/)[1])
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
test('ru marker widens the alphabet: Russian prose + typography + ASCII code -> clean', async () => {
|
|
58
|
-
const md = [
|
|
59
|
-
'<!-- tackbox: lang=ru personal experimental repo -->',
|
|
60
|
-
'# ' + PRIVET,
|
|
61
|
-
'',
|
|
62
|
-
// em-dash, guillemets, ellipsis, NBSP all allowed under ru
|
|
63
|
-
PRIVET + ' \u{2014} \u{AB}' + MIR + '\u{BB}\u{2026}\u{A0}end',
|
|
64
|
-
'',
|
|
65
|
-
'```bash',
|
|
66
|
-
'echo hi',
|
|
67
|
-
'```',
|
|
68
|
-
].join('\n')
|
|
69
|
-
const errs = await run(md)
|
|
70
|
-
assert.equal(errs.length, 0, JSON.stringify(errs))
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
test('ru marker still flags emoji and CJK, and only those', async () => {
|
|
74
|
-
const md = [
|
|
75
|
-
'<!-- tackbox: lang=ru note -->',
|
|
76
|
-
PRIVET + ' \u{1F680} \u{4E2D}', // rocket + CJK amid allowed Cyrillic
|
|
77
|
-
].join('\n')
|
|
78
|
-
const errs = await run(md)
|
|
79
|
-
assert.deepEqual(detailHexes(errs), ['1F680', '4E2D'])
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('no marker: Cyrillic is still flagged (default unchanged)', async () => {
|
|
83
|
-
const errs = await run(PRIVET + '\n')
|
|
84
|
-
assert.deepEqual(detailHexes(errs), ['41F', '440', '438', '432', '435', '442'])
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('marker below line 5 is a finding and does not widen', async () => {
|
|
88
|
-
const md = [
|
|
89
|
-
'line 1', 'line 2', 'line 3', 'line 4', 'line 5',
|
|
90
|
-
'<!-- tackbox: lang=ru too late -->',
|
|
91
|
-
PRIVET, // line 7
|
|
92
|
-
].join('\n')
|
|
93
|
-
const errs = await run(md)
|
|
94
|
-
const placement = errs.find(e => /within the first 5 lines/.test(e.errorDetail))
|
|
95
|
-
assert.ok(placement, 'expected a marker-placement finding: ' + JSON.stringify(errs))
|
|
96
|
-
assert.equal(placement.lineNumber, 6)
|
|
97
|
-
// File stays ASCII-only, so the Cyrillic on line 7 is still flagged.
|
|
98
|
-
assert.ok(errs.some(e => e.lineNumber === 7 && /U\+41F/.test(e.errorDetail)))
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
test('duplicate marker is a finding and leaves the file ASCII-only', async () => {
|
|
102
|
-
const md = [
|
|
103
|
-
'<!-- tackbox: lang=ru first -->',
|
|
104
|
-
'<!-- tackbox: lang=ru second -->',
|
|
105
|
-
PRIVET,
|
|
106
|
-
].join('\n')
|
|
107
|
-
const errs = await run(md)
|
|
108
|
-
const dup = errs.filter(e => /duplicate tackbox lang marker/.test(e.errorDetail))
|
|
109
|
-
assert.equal(dup.length, 1)
|
|
110
|
-
assert.equal(dup[0].lineNumber, 2)
|
|
111
|
-
assert.ok(errs.some(e => /U\+41F/.test(e.errorDetail)), 'Cyrillic still flagged')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
test('unknown language code is a finding and does not widen', async () => {
|
|
115
|
-
const errs = await run('<!-- tackbox: lang=xx -->\n' + PRIVET + '\n')
|
|
116
|
-
assert.ok(errs.some(e => /unsupported language code 'xx'/.test(e.errorDetail)))
|
|
117
|
-
assert.ok(errs.some(e => /U\+41F/.test(e.errorDetail)))
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
test('marker with no language code is a finding and does not widen', async () => {
|
|
121
|
-
const errs = await run('<!-- tackbox: lang= -->\n' + PRIVET + '\n')
|
|
122
|
-
assert.ok(errs.some(e => /missing a language code/.test(e.errorDetail)))
|
|
123
|
-
assert.ok(errs.some(e => /U\+41F/.test(e.errorDetail)))
|
|
124
|
-
})
|
package/js/tests/report.test.js
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
const { test } = require('node:test')
|
|
2
|
-
const assert = require('node:assert')
|
|
3
|
-
|
|
4
|
-
// report.js reads console, window/CustomEvent, and @sentry/browser at call time
|
|
5
|
-
// (globals + the shared module cache), so replacing them here reaches the module
|
|
6
|
-
// under test. A fake Sentry in require.cache dodges real init/network; load()
|
|
7
|
-
// reloads report.js per scenario for pristine ready/lastSent state.
|
|
8
|
-
const REPORT_PATH = require.resolve('../report.js')
|
|
9
|
-
const SENTRY_PATH = require.resolve('@sentry/browser')
|
|
10
|
-
|
|
11
|
-
console.log = () => {}
|
|
12
|
-
console.warn = () => {}
|
|
13
|
-
console.error = () => {}
|
|
14
|
-
|
|
15
|
-
let captured = [] // one entry per Sentry.captureException: { err, level, fingerprint, tags }
|
|
16
|
-
let dispatched = [] // one entry per window.dispatchEvent: the CustomEvent detail
|
|
17
|
-
let scope = null
|
|
18
|
-
|
|
19
|
-
const fakeSentry = {
|
|
20
|
-
init() {},
|
|
21
|
-
withScope(fn) {
|
|
22
|
-
scope = { level: undefined, fingerprint: undefined, tags: {} }
|
|
23
|
-
fn({
|
|
24
|
-
setLevel(l) { scope.level = l },
|
|
25
|
-
setFingerprint(f) { scope.fingerprint = f },
|
|
26
|
-
setTag(k, v) { scope.tags[k] = v },
|
|
27
|
-
})
|
|
28
|
-
scope = null
|
|
29
|
-
},
|
|
30
|
-
captureException(err) {
|
|
31
|
-
captured.push({
|
|
32
|
-
err,
|
|
33
|
-
level: scope && scope.level,
|
|
34
|
-
fingerprint: scope && scope.fingerprint,
|
|
35
|
-
tags: scope ? { ...scope.tags } : null,
|
|
36
|
-
})
|
|
37
|
-
},
|
|
38
|
-
}
|
|
39
|
-
require.cache[SENTRY_PATH] = { id: SENTRY_PATH, filename: SENTRY_PATH, loaded: true, exports: fakeSentry }
|
|
40
|
-
|
|
41
|
-
class FakeCustomEvent {
|
|
42
|
-
constructor(type, opts) {
|
|
43
|
-
this.type = type
|
|
44
|
-
this.detail = opts && opts.detail
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
Object.defineProperty(globalThis, 'CustomEvent', { value: FakeCustomEvent, configurable: true, writable: true })
|
|
48
|
-
Object.defineProperty(globalThis, 'window', {
|
|
49
|
-
value: { addEventListener() {}, dispatchEvent(ev) { dispatched.push(ev.detail); return true } },
|
|
50
|
-
configurable: true,
|
|
51
|
-
writable: true,
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const VALID_DSN = 'https://public@sentry.example.com/1'
|
|
55
|
-
|
|
56
|
-
function load() {
|
|
57
|
-
captured = []
|
|
58
|
-
dispatched = []
|
|
59
|
-
scope = null
|
|
60
|
-
delete require.cache[REPORT_PATH]
|
|
61
|
-
return require(REPORT_PATH)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// (a) DSN unset -> not ready -> capture disabled, but the user lane still fires.
|
|
65
|
-
test('report dispatches to the user lane even when not ready (DSN unset)', () => {
|
|
66
|
-
const report = load()
|
|
67
|
-
report.init({})
|
|
68
|
-
assert.equal(report.isReady(), false)
|
|
69
|
-
report.reportError('connection lost mid-stream', new Error('boom'), { area: 'net' }, 'net.conn')
|
|
70
|
-
assert.equal(dispatched.length, 1, 'user lane must dispatch without init')
|
|
71
|
-
assert.equal(captured.length, 0, 'capture stays gated off when not ready')
|
|
72
|
-
assert.equal(dispatched[0].msg, 'connection lost mid-stream')
|
|
73
|
-
assert.equal(dispatched[0].dedupKey, 'net.conn')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
// (b) same dedupKey inside the rate window: both dispatch, second capture dropped.
|
|
77
|
-
test('rate window drops the second capture but never the dispatch', () => {
|
|
78
|
-
const report = load()
|
|
79
|
-
report.init({ dsn: VALID_DSN })
|
|
80
|
-
assert.equal(report.isReady(), true)
|
|
81
|
-
report.reportError('poll failed on stale token', new Error('e1'), { area: 'poll' }, 'poll.stale')
|
|
82
|
-
report.reportError('poll failed on stale token', new Error('e2'), { area: 'poll' }, 'poll.stale')
|
|
83
|
-
assert.equal(dispatched.length, 2, 'every event reaches the user lane')
|
|
84
|
-
assert.equal(captured.length, 1, 'duplicate capture suppressed within the window')
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
// (D-5) re-entrancy guard: a throwing tackbox:error listener surfaces via
|
|
88
|
-
// window.onerror, which setupGlobalHandlers turns back into reportError ->
|
|
89
|
-
// dispatch on the same stack. The guard skips the nested dispatch so it cannot
|
|
90
|
-
// loop; sequential dispatches are unaffected.
|
|
91
|
-
test('a re-entering dispatch is skipped (no infinite loop, one outer dispatch)', () => {
|
|
92
|
-
const report = load()
|
|
93
|
-
report.init({})
|
|
94
|
-
let calls = 0
|
|
95
|
-
const realWindow = window
|
|
96
|
-
Object.defineProperty(globalThis, 'window', {
|
|
97
|
-
value: {
|
|
98
|
-
addEventListener() {},
|
|
99
|
-
dispatchEvent(ev) {
|
|
100
|
-
calls++
|
|
101
|
-
dispatched.push(ev.detail)
|
|
102
|
-
// the listener failure re-enters synchronously via reportError
|
|
103
|
-
report.reportError('re-entry from a listener failure', new Error('inner'), null, 'reentry.key')
|
|
104
|
-
return true
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
configurable: true,
|
|
108
|
-
writable: true,
|
|
109
|
-
})
|
|
110
|
-
try {
|
|
111
|
-
report.reportError('outer dispatch that re-enters', new Error('outer'), null, 'outer.key')
|
|
112
|
-
assert.equal(calls, 1, 'exactly one dispatch; the nested re-entry is skipped')
|
|
113
|
-
assert.equal(dispatched.length, 1, 'only the outer notice reaches the user lane')
|
|
114
|
-
} finally {
|
|
115
|
-
Object.defineProperty(globalThis, 'window', { value: realWindow, configurable: true, writable: true })
|
|
116
|
-
}
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
// (c) reportPanic routes to the user lane with level fatal and a per-name key.
|
|
120
|
-
test('reportPanic dispatches level fatal with panic:<name> dedupKey', () => {
|
|
121
|
-
const report = load()
|
|
122
|
-
report.reportPanic('worker', new Error('kaboom'))
|
|
123
|
-
assert.equal(dispatched.length, 1)
|
|
124
|
-
const d = dispatched[0]
|
|
125
|
-
assert.equal(d.level, 'fatal')
|
|
126
|
-
assert.equal(d.dedupKey, 'panic:worker')
|
|
127
|
-
assert.equal(d.msg, 'panic in worker')
|
|
128
|
-
assert.equal(d.cause.message, 'kaboom')
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
// (d) capture builds Error(msg) with the original error chained as .cause (seed shape).
|
|
132
|
-
test('capture is Error(msg) with the original cause chained', () => {
|
|
133
|
-
const report = load()
|
|
134
|
-
report.init({ dsn: VALID_DSN })
|
|
135
|
-
const original = new Error('socket hangup')
|
|
136
|
-
report.reportError('upload failed mid-flight', original, { area: 'upload' }, 'upload.fail')
|
|
137
|
-
assert.equal(captured.length, 1)
|
|
138
|
-
const { err, level, fingerprint, tags } = captured[0]
|
|
139
|
-
assert.ok(err instanceof Error)
|
|
140
|
-
assert.equal(err.message, 'upload failed mid-flight')
|
|
141
|
-
assert.equal(err.cause, original)
|
|
142
|
-
assert.equal(level, 'error')
|
|
143
|
-
assert.deepEqual(fingerprint, ['upload.fail'])
|
|
144
|
-
assert.equal(tags.area, 'upload')
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// (e) reportSynthError captures a plain Error(msg) with no cause chain.
|
|
148
|
-
test('reportSynthError captures Error(msg) with no cause', () => {
|
|
149
|
-
const report = load()
|
|
150
|
-
report.init({ dsn: VALID_DSN })
|
|
151
|
-
report.reportSynthError('non-OK response handled inline', { area: 'http' }, 'http.synth')
|
|
152
|
-
assert.equal(captured.length, 1)
|
|
153
|
-
assert.equal(captured[0].err.message, 'non-OK response handled inline')
|
|
154
|
-
assert.equal(captured[0].err.cause, undefined)
|
|
155
|
-
assert.equal(dispatched.length, 1)
|
|
156
|
-
assert.equal(dispatched[0].cause, null, 'synth user-lane detail carries cause: null')
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
// (f) reportQuiet captures at warning with no user-lane dispatch.
|
|
160
|
-
test('reportQuiet captures warning-level with no user-lane dispatch', () => {
|
|
161
|
-
const report = load()
|
|
162
|
-
report.init({ dsn: VALID_DSN })
|
|
163
|
-
report.reportQuiet('index rebuild degraded, using stale', new Error('timeout'), { area: 'idx' }, 'idx.stale')
|
|
164
|
-
assert.equal(captured.length, 1, 'quiet still captures')
|
|
165
|
-
assert.equal(captured[0].level, 'warning')
|
|
166
|
-
assert.deepEqual(captured[0].fingerprint, ['idx.stale'])
|
|
167
|
-
assert.equal(dispatched.length, 0, 'quiet must not touch the user lane')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
// (g) notify dispatches only, captures nothing, and consumes no rate slot.
|
|
171
|
-
test('notify dispatches level notice, captures nothing, leaves the rate slot', () => {
|
|
172
|
-
const report = load()
|
|
173
|
-
report.init({ dsn: VALID_DSN })
|
|
174
|
-
report.notify('you appear to be offline', new Error('net down'), { area: 'conn' }, 'conn.offline')
|
|
175
|
-
assert.equal(dispatched.length, 1)
|
|
176
|
-
assert.equal(dispatched[0].level, 'notice')
|
|
177
|
-
assert.equal(captured.length, 0, 'notify captures nothing')
|
|
178
|
-
// Same dedupKey still captures: notify consumed no rate slot.
|
|
179
|
-
report.reportError('still offline after retry', new Error('net down'), { area: 'conn' }, 'conn.offline')
|
|
180
|
-
assert.equal(captured.length, 1, 'following reportError on the notify key still captures')
|
|
181
|
-
assert.equal(dispatched.length, 2)
|
|
182
|
-
})
|