tackbox 0.1.0
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/LICENSE +21 -0
- package/README.md +552 -0
- package/bin/tackbox-eslint.js +147 -0
- package/bin/tackbox-mdlint.js +45 -0
- package/eslint.config.preset.js +21 -0
- package/js/README.md +209 -0
- package/js/eslint-plugin.js +48 -0
- package/js/markdownlint-rules/no-non-ascii.js +132 -0
- package/js/report.js +187 -0
- package/js/rules/_shared.js +809 -0
- package/js/rules/no-broad-notify.js +47 -0
- package/js/rules/no-console-error.js +23 -0
- package/js/rules/no-focused-test.js +22 -0
- package/js/rules/no-parse-fallback.js +150 -0
- package/js/rules/no-skipped-test.js +95 -0
- package/js/rules/no-swallow-allsettled.js +52 -0
- package/js/rules/no-swallow-catch.js +31 -0
- package/js/rules/no-swallow-promise-catch.js +38 -0
- package/js/rules/no-throw-and-report.js +30 -0
- package/js/rules/ts-exit-in-catch.js +26 -0
- package/js/rules/ts-rethrow-without-cause.js +62 -0
- package/js/rules/ts-useless-catch.js +25 -0
- package/js/rules/valid-dedup-key.js +50 -0
- package/js/rules/valid-error-report.js +99 -0
- package/js/tests/eslint-wrapper.test.js +53 -0
- package/js/tests/mdlint-wrapper.test.js +85 -0
- package/js/tests/no-non-ascii.test.js +124 -0
- package/js/tests/report.test.js +182 -0
- package/js/tests/reporters.test.js +223 -0
- package/js/tests/rules.test.js +650 -0
- package/js/tests/svelte.test.js +61 -0
- package/package.json +43 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const {
|
|
2
|
+
REPORTER_FULL, REPORTER_SYNTH,
|
|
3
|
+
tier1ReporterName, isTier1Notify, isStaticString, staticStringValue, isTestFile,
|
|
4
|
+
} = require('./_shared')
|
|
5
|
+
|
|
6
|
+
const MIN = 15
|
|
7
|
+
const MAX = 200
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
docs: { description: 'reporter/notify args: static 15-200 msg, cause non-null, tags non-empty, dedupKey present (notify shares the msg/cause/tags/dedupKey shape - D007/D008)' },
|
|
13
|
+
messages: {
|
|
14
|
+
noArgs: '{{name}} requires at least (msg, cause)',
|
|
15
|
+
msgNotStatic: '{{name}}: first arg (msg) must be a static string literal (no template interpolation)',
|
|
16
|
+
msgTooShort: '{{name}}: msg is {{len}} chars, must be at least {{min}}',
|
|
17
|
+
msgTooLong: '{{name}}: msg is {{len}} chars, must be at most {{max}}',
|
|
18
|
+
causeMissing: '{{name}}: second arg (cause) must be present and not null/undefined',
|
|
19
|
+
tagsEmpty: '{{name}}: tags arg must not be {} — drop the arg or supply real tags',
|
|
20
|
+
dedupMissing: '{{name}}: dedupKey is required (last arg) — spec mandates per-site dedupKey',
|
|
21
|
+
},
|
|
22
|
+
schema: [],
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
if (isTestFile(context)) return {}
|
|
26
|
+
return {
|
|
27
|
+
CallExpression(node) {
|
|
28
|
+
let name = tier1ReporterName(context, node)
|
|
29
|
+
// notify is not a reporter (never credits a swallow), but its user-lane
|
|
30
|
+
// msg must be a static literal (D007) and it carries the full
|
|
31
|
+
// (msg, cause, tags, dedupKey) shape, so it is validated like one.
|
|
32
|
+
let notifyVerb = false
|
|
33
|
+
if (!name) {
|
|
34
|
+
if (isTier1Notify(context, node)) {
|
|
35
|
+
name = 'notify'
|
|
36
|
+
notifyVerb = true
|
|
37
|
+
} else {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const args = node.arguments
|
|
43
|
+
if (args.length < 1) {
|
|
44
|
+
context.report({ node, messageId: 'noArgs', data: { name } })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const msg = args[0]
|
|
49
|
+
if (!isStaticString(msg)) {
|
|
50
|
+
context.report({ node: msg, messageId: 'msgNotStatic', data: { name } })
|
|
51
|
+
} else {
|
|
52
|
+
const v = staticStringValue(msg)
|
|
53
|
+
if (v.length < MIN) {
|
|
54
|
+
context.report({ node: msg, messageId: 'msgTooShort', data: { name, len: v.length, min: MIN } })
|
|
55
|
+
} else if (v.length > MAX) {
|
|
56
|
+
context.report({ node: msg, messageId: 'msgTooLong', data: { name, len: v.length, max: MAX } })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isFull = REPORTER_FULL.has(name) || notifyVerb
|
|
61
|
+
const isSynth = REPORTER_SYNTH.has(name)
|
|
62
|
+
|
|
63
|
+
// (msg, cause, tags, dedupKey) for full reporters.
|
|
64
|
+
if (isFull) {
|
|
65
|
+
if (args.length < 2) {
|
|
66
|
+
context.report({ node, messageId: 'causeMissing', data: { name } })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const cause = args[1]
|
|
70
|
+
if (cause.type === 'Literal' && (cause.value === null || cause.value === undefined)) {
|
|
71
|
+
context.report({ node: cause, messageId: 'causeMissing', data: { name } })
|
|
72
|
+
}
|
|
73
|
+
if (cause.type === 'Identifier' && cause.name === 'undefined') {
|
|
74
|
+
context.report({ node: cause, messageId: 'causeMissing', data: { name } })
|
|
75
|
+
}
|
|
76
|
+
const tags = args[2]
|
|
77
|
+
if (tags && tags.type === 'ObjectExpression' && tags.properties.length === 0) {
|
|
78
|
+
context.report({ node: tags, messageId: 'tagsEmpty', data: { name } })
|
|
79
|
+
}
|
|
80
|
+
if (args.length < 4) {
|
|
81
|
+
context.report({ node, messageId: 'dedupMissing', data: { name } })
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// (msg, tags, dedupKey) for synth reporters.
|
|
87
|
+
if (isSynth) {
|
|
88
|
+
const tags = args[1]
|
|
89
|
+
if (tags && tags.type === 'ObjectExpression' && tags.properties.length === 0) {
|
|
90
|
+
context.report({ node: tags, messageId: 'tagsEmpty', data: { name } })
|
|
91
|
+
}
|
|
92
|
+
if (args.length < 3) {
|
|
93
|
+
context.report({ node, messageId: 'dedupMissing', data: { name } })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
})
|