@djangocfg/devtools 2.1.566 → 2.1.569
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/package.json +9 -6
- package/src/__tests__/formatConsoleArgs.test.ts +95 -0
- package/src/capture.ts +57 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/devtools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.569",
|
|
4
4
|
"description": "Frontend devtools for django-cfg apps: error/console capture with backend ingest, plus a floating debug panel. One package, one event stream.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"django",
|
|
@@ -46,19 +46,21 @@
|
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
48
|
"check": "tsc --noEmit",
|
|
49
|
-
"lint": "eslint ."
|
|
49
|
+
"lint": "eslint .",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"test:watch": "vitest"
|
|
50
52
|
},
|
|
51
53
|
"peerDependencies": {
|
|
52
|
-
"@djangocfg/ui-core": "^2.1.
|
|
54
|
+
"@djangocfg/ui-core": "^2.1.569",
|
|
53
55
|
"lucide-react": ">=0.400.0",
|
|
54
56
|
"react": "^19.0.0",
|
|
55
57
|
"react-dom": "^19.0.0",
|
|
56
58
|
"zustand": "^5.0.0"
|
|
57
59
|
},
|
|
58
60
|
"devDependencies": {
|
|
59
|
-
"@djangocfg/eslint-config": "^2.1.
|
|
60
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
61
|
-
"@djangocfg/ui-core": "^2.1.
|
|
61
|
+
"@djangocfg/eslint-config": "^2.1.569",
|
|
62
|
+
"@djangocfg/typescript-config": "^2.1.569",
|
|
63
|
+
"@djangocfg/ui-core": "^2.1.569",
|
|
62
64
|
"@storybook/react-vite": "^10.5.0",
|
|
63
65
|
"@types/node": "^25.9.5",
|
|
64
66
|
"@types/react": "19.2.15",
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
"lucide-react": "^0.545.0",
|
|
67
69
|
"storybook": "^10.5.0",
|
|
68
70
|
"typescript": "^5.9.3",
|
|
71
|
+
"vitest": "^4.1.10",
|
|
69
72
|
"zustand": "^5.0.14"
|
|
70
73
|
}
|
|
71
74
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatConsoleArgs } from '../capture';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What reaches the alert has to say what broke.
|
|
7
|
+
*
|
|
8
|
+
* consola styles its tag with `%c`, so one logger call arrives as
|
|
9
|
+
* ["%capi:auth%c message", "<css>", "", err]. Joined on a space, the
|
|
10
|
+
* stylesheet became the message and the truncation then cut the sentence that
|
|
11
|
+
* mattered — the Telegram alert read "background: #c0392b; border-radius…".
|
|
12
|
+
*/
|
|
13
|
+
describe('formatConsoleArgs', () => {
|
|
14
|
+
describe('the production case', () => {
|
|
15
|
+
it('drops the stylesheet and keeps the sentence', () => {
|
|
16
|
+
const css = '\n background: #c0392b;\n border-radius: 0.5em;\n color: white;\n ';
|
|
17
|
+
const out = formatConsoleArgs([
|
|
18
|
+
'%capi:auth:error%c Verify OTP failed:',
|
|
19
|
+
css,
|
|
20
|
+
'',
|
|
21
|
+
new Error('HTTP 401: '),
|
|
22
|
+
]);
|
|
23
|
+
// The trailing space is the Error's own message, not our formatting.
|
|
24
|
+
expect(out).toBe('api:auth:error Verify OTP failed: HTTP 401: ');
|
|
25
|
+
expect(out).not.toContain('background');
|
|
26
|
+
expect(out).not.toContain('%c');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('format specifiers', () => {
|
|
31
|
+
it('substitutes %s', () => {
|
|
32
|
+
expect(formatConsoleArgs(['hello %s', 'world'])).toBe('hello world');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
['%d', 42.9, '42'],
|
|
37
|
+
['%i', 42.9, '42'],
|
|
38
|
+
['%f', 1.5, '1.5'],
|
|
39
|
+
])('substitutes %s', (spec, input, expected) => {
|
|
40
|
+
expect(formatConsoleArgs([`n=${spec}`, input])).toBe(`n=${expected}`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('serialises %o and %j', () => {
|
|
44
|
+
expect(formatConsoleArgs(['%o', { a: 1 }])).toBe('{"a":1}');
|
|
45
|
+
expect(formatConsoleArgs(['%j', { a: 1 }])).toBe('{"a":1}');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('treats %% as a literal percent and consumes no argument', () => {
|
|
49
|
+
expect(formatConsoleArgs(['100%% done %s', 'ok'])).toBe('100% done ok');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('appends arguments the format string does not claim', () => {
|
|
53
|
+
/* What the browser does with extras — dropping them would lose the
|
|
54
|
+
error object, which is the only part worth reading. */
|
|
55
|
+
expect(formatConsoleArgs(['%s:', 'ctx', 'detail', 42])).toBe('ctx: detail 42');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps an unmatched specifier rather than inventing a value', () => {
|
|
59
|
+
expect(formatConsoleArgs(['a %s b %s', 'one'])).toBe('a one b %s');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('when there is nothing to substitute', () => {
|
|
64
|
+
it('joins plain arguments on a space, as before', () => {
|
|
65
|
+
expect(formatConsoleArgs(['failed:', 'because'])).toBe('failed: because');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('leaves a non-string first argument alone', () => {
|
|
69
|
+
expect(formatConsoleArgs([{ a: 1 }, 'tail'])).toBe('{"a":1} tail');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('uses an Error message, not its stringification', () => {
|
|
73
|
+
expect(formatConsoleArgs(['boom:', new Error('the reason')])).toBe('boom: the reason');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('survives a value JSON cannot serialise', () => {
|
|
77
|
+
const cyclic: Record<string, unknown> = {};
|
|
78
|
+
cyclic.self = cyclic;
|
|
79
|
+
expect(() => formatConsoleArgs(['x', cyclic])).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('edge cases that must not throw', () => {
|
|
84
|
+
it.each([[[]], [[undefined]], [[null]], [['%c']], [['%c', 'css']]])(
|
|
85
|
+
'handles %j',
|
|
86
|
+
(args) => {
|
|
87
|
+
expect(() => formatConsoleArgs(args as unknown[])).not.toThrow();
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
it('produces nothing but the tag when the message is only a style', () => {
|
|
92
|
+
expect(formatConsoleArgs(['%ctag%c', 'css', ''])).toBe('tag');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/capture.ts
CHANGED
|
@@ -92,22 +92,64 @@ export function installJsErrorCapture(): () => void {
|
|
|
92
92
|
|
|
93
93
|
// ─── console.warn / console.error ────────────────────────────────────────────
|
|
94
94
|
|
|
95
|
+
function stringifyOne(a: unknown): string {
|
|
96
|
+
let s: string
|
|
97
|
+
if (typeof a === 'string') s = a
|
|
98
|
+
else if (a instanceof Error) s = a.message
|
|
99
|
+
else {
|
|
100
|
+
try {
|
|
101
|
+
// `JSON.stringify(undefined)` returns undefined, not a string, and a
|
|
102
|
+
// function or a symbol does the same — `truncate` then throws on
|
|
103
|
+
// `.length` and the capture swallows the whole event.
|
|
104
|
+
s = JSON.stringify(a) ?? String(a)
|
|
105
|
+
} catch {
|
|
106
|
+
s = String(a)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return truncate(s, ARG_MAX)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Apply console format specifiers the way the browser does.
|
|
114
|
+
*
|
|
115
|
+
* consola styles its tag with `%c`, so a single logger call arrives here as
|
|
116
|
+
* ["%capi:auth%c Verify OTP failed:", "<css>", "", err]. Joining that on a
|
|
117
|
+
* space put the stylesheet INTO the message — the Telegram alert read
|
|
118
|
+
* "background: #c0392b; border-radius: 0.5em; ..." and the sentence that said
|
|
119
|
+
* what had failed was what the truncation then cut. Every styled log from
|
|
120
|
+
* consola, and anything else using %c, was unreadable for the same reason.
|
|
121
|
+
*
|
|
122
|
+
* Only the specifiers a console actually consumes are handled: `%c` swallows
|
|
123
|
+
* its argument and contributes nothing, `%s`/`%d`/`%i`/`%f`/`%o`/`%O`/`%j`
|
|
124
|
+
* substitute theirs. `%%` is a literal percent and consumes nothing. Anything
|
|
125
|
+
* left over is appended, which is what the browser does with extra arguments.
|
|
126
|
+
*
|
|
127
|
+
* Not applied when the first argument is not a string, or carries no
|
|
128
|
+
* specifier: then there is nothing to substitute and the old join is right.
|
|
129
|
+
*/
|
|
130
|
+
export function formatConsoleArgs(args: unknown[]): string {
|
|
131
|
+
const [first, ...rest] = args
|
|
132
|
+
if (typeof first !== 'string' || !/%[csdifoOj%]/.test(first)) {
|
|
133
|
+
return args.map(stringifyOne).join(' ')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let i = 0
|
|
137
|
+
const out = first.replace(/%([csdifoOj%])/g, (match, spec: string) => {
|
|
138
|
+
if (spec === '%') return '%'
|
|
139
|
+
if (i >= rest.length) return match // no argument left: the browser keeps the token
|
|
140
|
+
const arg = rest[i++]
|
|
141
|
+
if (spec === 'c') return '' // a style, not content
|
|
142
|
+
if (spec === 'd' || spec === 'i') return String(Math.trunc(Number(arg)))
|
|
143
|
+
if (spec === 'f') return String(Number(arg))
|
|
144
|
+
return stringifyOne(arg)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const unused = rest.slice(i).map(stringifyOne)
|
|
148
|
+
return truncate([out.trim(), ...unused].filter(Boolean).join(' '), ARG_MAX * 4)
|
|
149
|
+
}
|
|
150
|
+
|
|
95
151
|
function stringifyArgs(args: unknown[]): string {
|
|
96
|
-
return args
|
|
97
|
-
.map((a) => {
|
|
98
|
-
let s: string
|
|
99
|
-
if (typeof a === 'string') s = a
|
|
100
|
-
else if (a instanceof Error) s = a.message
|
|
101
|
-
else {
|
|
102
|
-
try {
|
|
103
|
-
s = JSON.stringify(a)
|
|
104
|
-
} catch {
|
|
105
|
-
s = String(a)
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return truncate(s, ARG_MAX)
|
|
109
|
-
})
|
|
110
|
-
.join(' ')
|
|
152
|
+
return formatConsoleArgs(args)
|
|
111
153
|
}
|
|
112
154
|
|
|
113
155
|
export function installConsoleCapture(): () => void {
|