tackbox 0.1.0 → 0.1.62
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 +11 -9
- package/package.json +6 -2
- 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
|
@@ -174,7 +174,7 @@ fetched separately and cached per version:
|
|
|
174
174
|
|
|
175
175
|
After the first fetch tackbox runs fully offline until the engine
|
|
176
176
|
version changes. Platform wheels cover Linux x86_64/arm64 (manylinux),
|
|
177
|
-
macOS
|
|
177
|
+
macOS arm64, and Windows x86_64. `engines.json` in the thin
|
|
178
178
|
wheel records the source, version, sha256, and license of every
|
|
179
179
|
bundled binary and dependency; `tackbox doctor` fetches the store if
|
|
180
180
|
absent and verifies the payload against it.
|
|
@@ -187,9 +187,8 @@ test-skip rules (via the `pyrules` flake8 plugin), frontend swallow,
|
|
|
187
187
|
notify, and test-skip rules (JS, TS, Svelte, via ESLint), and Markdown
|
|
188
188
|
(MD001-060 + ASCII).
|
|
189
189
|
|
|
190
|
-
See `go/README.md` for the Go ruleset.
|
|
191
|
-
|
|
192
|
-
outside this repo (private notes); the public summary:
|
|
190
|
+
See `go/README.md` for the complete Go ruleset. Across supported
|
|
191
|
+
languages, the core policy is:
|
|
193
192
|
|
|
194
193
|
- Every `err != nil` branch must propagate, capture, or carry an
|
|
195
194
|
explicit `// no-report: <reason>` marker.
|
|
@@ -201,8 +200,9 @@ outside this repo (private notes); the public summary:
|
|
|
201
200
|
- Bare `return nil` from a single-result function must carry
|
|
202
201
|
`// nil-return: <reason>` or use `(val, ok)` / `(val, err)`.
|
|
203
202
|
- A single err-branch may not both capture and `return err`.
|
|
204
|
-
-
|
|
205
|
-
|
|
203
|
+
- The dedupKey must be a well-formed literal; in Go, capture-call
|
|
204
|
+
arguments must additionally not carry raw user input (a
|
|
205
|
+
`*http.Request` field).
|
|
206
206
|
- A `notify` (user lane only, no capture) may terminate a failure path
|
|
207
207
|
only when it is narrowed: a narrow catch type (Java/Python) or an
|
|
208
208
|
additional condition inside the branch (Go/JS). An unconditional
|
|
@@ -366,9 +366,11 @@ contract.
|
|
|
366
366
|
Dedup lives at two levels with different owners
|
|
367
367
|
(`docs/report-contracts.md` D005):
|
|
368
368
|
|
|
369
|
-
- The capture helpers rate-limit telemetry: repeat
|
|
370
|
-
same dedupKey inside the rate window (default 60s)
|
|
371
|
-
|
|
369
|
+
- The capture helpers rate-limit telemetry: a repeat capture with the
|
|
370
|
+
same dedupKey inside the rate window (default 60s) is dropped
|
|
371
|
+
client-side, so the server never sees it. Lossy for in-window repeats
|
|
372
|
+
(their occurrence count and any changed context are lost); captures
|
|
373
|
+
that pass the window reach the server, which groups by fingerprint.
|
|
372
374
|
- The user lane is never suppressed by the helpers. Every user-facing
|
|
373
375
|
event is delivered carrying its dedupKey; collapsing a storm into
|
|
374
376
|
one live banner or a counter is presentation policy and belongs to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tackbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62",
|
|
4
4
|
"description": "ESLint plugin + browser report helper: every failure must report, propagate, or explain itself.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./js/eslint-plugin.js",
|
|
@@ -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
|
-
})
|