makethisbetter 1.0.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 +62 -0
- package/README.md +113 -0
- package/dist/annotate/draw.d.ts +21 -0
- package/dist/annotate/draw.d.ts.map +1 -0
- package/dist/annotate/draw.test.d.ts +2 -0
- package/dist/annotate/draw.test.d.ts.map +1 -0
- package/dist/annotate/highlighter.d.ts +11 -0
- package/dist/annotate/highlighter.d.ts.map +1 -0
- package/dist/annotate/highlighter.test.d.ts +2 -0
- package/dist/annotate/highlighter.test.d.ts.map +1 -0
- package/dist/annotate/overlay.d.ts +17 -0
- package/dist/annotate/overlay.d.ts.map +1 -0
- package/dist/annotate/overlay.test.d.ts +2 -0
- package/dist/annotate/overlay.test.d.ts.map +1 -0
- package/dist/annotate/pin.d.ts +10 -0
- package/dist/annotate/pin.d.ts.map +1 -0
- package/dist/annotate/pin.test.d.ts +2 -0
- package/dist/annotate/pin.test.d.ts.map +1 -0
- package/dist/annotate/session.d.ts +20 -0
- package/dist/annotate/session.d.ts.map +1 -0
- package/dist/annotate/session.test.d.ts +2 -0
- package/dist/annotate/session.test.d.ts.map +1 -0
- package/dist/annotate/toolbar.d.ts +17 -0
- package/dist/annotate/toolbar.d.ts.map +1 -0
- package/dist/annotate/toolbar.test.d.ts +2 -0
- package/dist/annotate/toolbar.test.d.ts.map +1 -0
- package/dist/api/client.d.ts +10 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.test.d.ts +2 -0
- package/dist/api/client.test.d.ts.map +1 -0
- package/dist/context/collector.d.ts +10 -0
- package/dist/context/collector.d.ts.map +1 -0
- package/dist/context/collector.test.d.ts +2 -0
- package/dist/context/collector.test.d.ts.map +1 -0
- package/dist/context/console.d.ts +10 -0
- package/dist/context/console.d.ts.map +1 -0
- package/dist/context/console.test.d.ts +2 -0
- package/dist/context/console.test.d.ts.map +1 -0
- package/dist/context/error-source.d.ts +21 -0
- package/dist/context/error-source.d.ts.map +1 -0
- package/dist/context/error-source.test.d.ts +2 -0
- package/dist/context/error-source.test.d.ts.map +1 -0
- package/dist/context/frustration.d.ts +27 -0
- package/dist/context/frustration.d.ts.map +1 -0
- package/dist/context/frustration.test.d.ts +2 -0
- package/dist/context/frustration.test.d.ts.map +1 -0
- package/dist/global.d.ts +3 -0
- package/dist/global.d.ts.map +1 -0
- package/dist/i18n/en.d.ts +47 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/index.d.ts +4 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.test.d.ts +2 -0
- package/dist/i18n/index.test.d.ts.map +1 -0
- package/dist/i18n/zh-CN.d.ts +47 -0
- package/dist/i18n/zh-CN.d.ts.map +1 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/makethisbetter.cjs.js +84 -0
- package/dist/makethisbetter.cjs.js.map +1 -0
- package/dist/makethisbetter.esm.js +1433 -0
- package/dist/makethisbetter.esm.js.map +1 -0
- package/dist/makethisbetter.js +84 -0
- package/dist/makethisbetter.js.map +1 -0
- package/dist/popup/comment.d.ts +31 -0
- package/dist/popup/comment.d.ts.map +1 -0
- package/dist/popup/comment.test.d.ts +2 -0
- package/dist/popup/comment.test.d.ts.map +1 -0
- package/dist/popup/success.d.ts +8 -0
- package/dist/popup/success.d.ts.map +1 -0
- package/dist/popup/success.test.d.ts +2 -0
- package/dist/popup/success.test.d.ts.map +1 -0
- package/dist/record/control-bar.d.ts +12 -0
- package/dist/record/control-bar.d.ts.map +1 -0
- package/dist/record/control-bar.test.d.ts +2 -0
- package/dist/record/control-bar.test.d.ts.map +1 -0
- package/dist/record/session.d.ts +26 -0
- package/dist/record/session.d.ts.map +1 -0
- package/dist/record/session.test.d.ts +2 -0
- package/dist/record/session.test.d.ts.map +1 -0
- package/dist/screenshot/capture.d.ts +3 -0
- package/dist/screenshot/capture.d.ts.map +1 -0
- package/dist/screenshot/capture.test.d.ts +2 -0
- package/dist/screenshot/capture.test.d.ts.map +1 -0
- package/dist/styles.d.ts +9 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/widget/controller.d.ts +37 -0
- package/dist/widget/controller.d.ts.map +1 -0
- package/dist/widget/controller.test.d.ts +2 -0
- package/dist/widget/controller.test.d.ts.map +1 -0
- package/dist/widget/frustration-prompt.d.ts +11 -0
- package/dist/widget/frustration-prompt.d.ts.map +1 -0
- package/dist/widget/frustration-prompt.test.d.ts +2 -0
- package/dist/widget/frustration-prompt.test.d.ts.map +1 -0
- package/dist/widget/payload.d.ts +5 -0
- package/dist/widget/payload.d.ts.map +1 -0
- package/dist/widget/payload.test.d.ts +2 -0
- package/dist/widget/payload.test.d.ts.map +1 -0
- package/dist/widget/pet.d.ts +12 -0
- package/dist/widget/pet.d.ts.map +1 -0
- package/dist/widget/pet.test.d.ts +2 -0
- package/dist/widget/pet.test.d.ts.map +1 -0
- package/dist/widget/shadow.d.ts +11 -0
- package/dist/widget/shadow.d.ts.map +1 -0
- package/dist/widget/shadow.test.d.ts +2 -0
- package/dist/widget/shadow.test.d.ts.map +1 -0
- package/dist/widget/tab.d.ts +10 -0
- package/dist/widget/tab.d.ts.map +1 -0
- package/dist/widget/tab.test.d.ts +2 -0
- package/dist/widget/tab.test.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/annotate/draw.test.ts +168 -0
- package/src/annotate/draw.ts +108 -0
- package/src/annotate/highlighter.test.ts +47 -0
- package/src/annotate/highlighter.ts +84 -0
- package/src/annotate/overlay.test.ts +91 -0
- package/src/annotate/overlay.ts +44 -0
- package/src/annotate/pin.test.ts +87 -0
- package/src/annotate/pin.ts +28 -0
- package/src/annotate/session.test.ts +219 -0
- package/src/annotate/session.ts +104 -0
- package/src/annotate/toolbar.test.ts +132 -0
- package/src/annotate/toolbar.ts +81 -0
- package/src/api/client.test.ts +75 -0
- package/src/api/client.ts +77 -0
- package/src/context/collector.test.ts +14 -0
- package/src/context/collector.ts +41 -0
- package/src/context/console.test.ts +44 -0
- package/src/context/console.ts +34 -0
- package/src/context/error-source.test.ts +115 -0
- package/src/context/error-source.ts +62 -0
- package/src/context/frustration.test.ts +134 -0
- package/src/context/frustration.ts +145 -0
- package/src/global.ts +3 -0
- package/src/i18n/en.ts +47 -0
- package/src/i18n/index.test.ts +24 -0
- package/src/i18n/index.ts +16 -0
- package/src/i18n/zh-CN.ts +47 -0
- package/src/index.ts +32 -0
- package/src/popup/comment.test.ts +73 -0
- package/src/popup/comment.ts +177 -0
- package/src/popup/success.test.ts +75 -0
- package/src/popup/success.ts +33 -0
- package/src/record/control-bar.test.ts +110 -0
- package/src/record/control-bar.ts +46 -0
- package/src/record/session.test.ts +153 -0
- package/src/record/session.ts +139 -0
- package/src/screenshot/capture.test.ts +68 -0
- package/src/screenshot/capture.ts +96 -0
- package/src/styles/widget.css +847 -0
- package/src/styles.ts +9 -0
- package/src/types.ts +46 -0
- package/src/vite-env.d.ts +6 -0
- package/src/widget/controller.test.ts +62 -0
- package/src/widget/controller.ts +249 -0
- package/src/widget/frustration-prompt.test.ts +86 -0
- package/src/widget/frustration-prompt.ts +34 -0
- package/src/widget/payload.test.ts +167 -0
- package/src/widget/payload.ts +31 -0
- package/src/widget/pet.test.ts +63 -0
- package/src/widget/pet.ts +67 -0
- package/src/widget/shadow.test.ts +25 -0
- package/src/widget/shadow.ts +41 -0
- package/src/widget/tab.test.ts +92 -0
- package/src/widget/tab.ts +31 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { ShadowContainer } from '../widget/shadow'
|
|
3
|
+
import { AnnotationToolbar } from './toolbar'
|
|
4
|
+
import { getMessages } from '../i18n'
|
|
5
|
+
|
|
6
|
+
describe('AnnotationToolbar', () => {
|
|
7
|
+
let shadow: ShadowContainer
|
|
8
|
+
let toolbar: AnnotationToolbar
|
|
9
|
+
const messages = getMessages('en')
|
|
10
|
+
|
|
11
|
+
function setup(opts?: { onModeChange?: (mode: 'markup' | 'record') => void }) {
|
|
12
|
+
shadow = new ShadowContainer()
|
|
13
|
+
toolbar = new AnnotationToolbar(shadow, messages, vi.fn(), opts?.onModeChange)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
document.body.innerHTML = ''
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('constructor', () => {
|
|
21
|
+
it('creates a toolbar element in the shadow root', () => {
|
|
22
|
+
setup()
|
|
23
|
+
expect(shadow.root.querySelector('.mtb-toolbar')).toBeInstanceOf(HTMLDivElement)
|
|
24
|
+
toolbar.destroy()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('renders markup and record buttons', () => {
|
|
28
|
+
setup()
|
|
29
|
+
expect(shadow.root.querySelector('.mtb-toolbar-mode-markup')).toBeInstanceOf(HTMLButtonElement)
|
|
30
|
+
expect(shadow.root.querySelector('.mtb-toolbar-mode-record')).toBeInstanceOf(HTMLButtonElement)
|
|
31
|
+
toolbar.destroy()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders the hint text', () => {
|
|
35
|
+
setup()
|
|
36
|
+
const hint = shadow.root.querySelector('.mtb-toolbar-hint')
|
|
37
|
+
expect(hint!.textContent).toBe(messages.toolbar.hint)
|
|
38
|
+
toolbar.destroy()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders an exit button', () => {
|
|
42
|
+
setup()
|
|
43
|
+
expect(shadow.root.querySelector('.mtb-exit-btn')).toBeInstanceOf(HTMLButtonElement)
|
|
44
|
+
toolbar.destroy()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('starts in markup mode with markup button active', () => {
|
|
48
|
+
setup()
|
|
49
|
+
expect(toolbar.getMode()).toBe('markup')
|
|
50
|
+
const markupBtn = shadow.root.querySelector('.mtb-toolbar-mode-markup')!
|
|
51
|
+
expect(markupBtn.classList.contains('active')).toBe(true)
|
|
52
|
+
toolbar.destroy()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('setMode', () => {
|
|
57
|
+
it('switches to record mode and updates active class', () => {
|
|
58
|
+
setup()
|
|
59
|
+
toolbar.setMode('record')
|
|
60
|
+
expect(toolbar.getMode()).toBe('record')
|
|
61
|
+
|
|
62
|
+
const markupBtn = shadow.root.querySelector('.mtb-toolbar-mode-markup')!
|
|
63
|
+
const recordBtn = shadow.root.querySelector('.mtb-toolbar-mode-record')!
|
|
64
|
+
expect(markupBtn.classList.contains('active')).toBe(false)
|
|
65
|
+
expect(recordBtn.classList.contains('active')).toBe(true)
|
|
66
|
+
|
|
67
|
+
const hint = shadow.root.querySelector('.mtb-toolbar-hint')!
|
|
68
|
+
expect(hint.textContent).toBe(messages.toolbar.hintRecord)
|
|
69
|
+
toolbar.destroy()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('switches back to markup mode', () => {
|
|
73
|
+
setup()
|
|
74
|
+
toolbar.setMode('record')
|
|
75
|
+
toolbar.setMode('markup')
|
|
76
|
+
expect(toolbar.getMode()).toBe('markup')
|
|
77
|
+
|
|
78
|
+
const hint = shadow.root.querySelector('.mtb-toolbar-hint')!
|
|
79
|
+
expect(hint.textContent).toBe(messages.toolbar.hint)
|
|
80
|
+
toolbar.destroy()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('button clicks', () => {
|
|
85
|
+
it('calls onModeChange when markup button is clicked', () => {
|
|
86
|
+
const onModeChange = vi.fn()
|
|
87
|
+
setup({ onModeChange })
|
|
88
|
+
const markupBtn = shadow.root.querySelector('.mtb-toolbar-mode-markup')!
|
|
89
|
+
markupBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
90
|
+
expect(onModeChange).toHaveBeenCalledWith('markup')
|
|
91
|
+
toolbar.destroy()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('calls onModeChange when record button is clicked', () => {
|
|
95
|
+
const onModeChange = vi.fn()
|
|
96
|
+
setup({ onModeChange })
|
|
97
|
+
const recordBtn = shadow.root.querySelector('.mtb-toolbar-mode-record')!
|
|
98
|
+
recordBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
99
|
+
expect(onModeChange).toHaveBeenCalledWith('record')
|
|
100
|
+
toolbar.destroy()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('calls onExit when exit button is clicked', () => {
|
|
104
|
+
shadow = new ShadowContainer()
|
|
105
|
+
const onExit = vi.fn()
|
|
106
|
+
toolbar = new AnnotationToolbar(shadow, messages, onExit)
|
|
107
|
+
|
|
108
|
+
const exitBtn = shadow.root.querySelector('.mtb-exit-btn')!
|
|
109
|
+
exitBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
110
|
+
expect(onExit).toHaveBeenCalledOnce()
|
|
111
|
+
toolbar.destroy()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('works without onModeChange callback', () => {
|
|
115
|
+
setup()
|
|
116
|
+
const recordBtn = shadow.root.querySelector('.mtb-toolbar-mode-record')!
|
|
117
|
+
expect(() => {
|
|
118
|
+
recordBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
119
|
+
}).not.toThrow()
|
|
120
|
+
expect(toolbar.getMode()).toBe('record')
|
|
121
|
+
toolbar.destroy()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('destroy', () => {
|
|
126
|
+
it('removes the toolbar element from the shadow root', () => {
|
|
127
|
+
setup()
|
|
128
|
+
toolbar.destroy()
|
|
129
|
+
expect(shadow.root.querySelector('.mtb-toolbar')).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ShadowContainer } from '../widget/shadow'
|
|
2
|
+
import type { I18nMessages } from '../i18n'
|
|
3
|
+
|
|
4
|
+
export type ToolbarMode = 'markup' | 'record'
|
|
5
|
+
|
|
6
|
+
export class AnnotationToolbar {
|
|
7
|
+
private el: HTMLDivElement
|
|
8
|
+
private hintEl!: HTMLSpanElement
|
|
9
|
+
private markupBtn!: HTMLButtonElement
|
|
10
|
+
private recordBtn!: HTMLButtonElement
|
|
11
|
+
private messages: I18nMessages
|
|
12
|
+
private currentMode: ToolbarMode = 'markup'
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
shadow: ShadowContainer,
|
|
16
|
+
messages: I18nMessages,
|
|
17
|
+
onExit: () => void,
|
|
18
|
+
onModeChange?: (mode: ToolbarMode) => void,
|
|
19
|
+
) {
|
|
20
|
+
this.messages = messages
|
|
21
|
+
|
|
22
|
+
this.el = shadow.el<HTMLDivElement>('div', 'mtb-toolbar')
|
|
23
|
+
this.el.innerHTML = this.buildHTML(messages)
|
|
24
|
+
shadow.append(this.el)
|
|
25
|
+
|
|
26
|
+
this.hintEl = this.el.querySelector('.mtb-toolbar-hint')!
|
|
27
|
+
this.markupBtn = this.el.querySelector('.mtb-toolbar-mode-markup')!
|
|
28
|
+
this.recordBtn = this.el.querySelector('.mtb-toolbar-mode-record')!
|
|
29
|
+
|
|
30
|
+
this.markupBtn.addEventListener('click', () => {
|
|
31
|
+
this.setMode('markup')
|
|
32
|
+
onModeChange?.('markup')
|
|
33
|
+
})
|
|
34
|
+
this.recordBtn.addEventListener('click', () => {
|
|
35
|
+
this.setMode('record')
|
|
36
|
+
onModeChange?.('record')
|
|
37
|
+
})
|
|
38
|
+
this.el.querySelector('.mtb-exit-btn')!.addEventListener('click', onExit)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private buildHTML(m: I18nMessages): string {
|
|
42
|
+
return `
|
|
43
|
+
<div class="mtb-toolbar-modes">
|
|
44
|
+
<button class="mtb-toolbar-mode-btn mtb-toolbar-mode-markup active">
|
|
45
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="3" x2="12" y2="7"/><line x1="12" y1="17" x2="12" y2="21"/><line x1="3" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="21" y2="12"/></svg>${m.toolbar.markup}
|
|
46
|
+
</button>
|
|
47
|
+
<button class="mtb-toolbar-mode-btn mtb-toolbar-mode-record">
|
|
48
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/></svg>${m.toolbar.record}
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
<span class="mtb-toolbar-hint">${m.toolbar.hint}</span>
|
|
52
|
+
<span class="mtb-toolbar-sep"></span>
|
|
53
|
+
<button class="mtb-exit-btn">
|
|
54
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
|
55
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
56
|
+
</svg>${m.toolbar.exit}
|
|
57
|
+
</button>
|
|
58
|
+
`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setMode(mode: ToolbarMode): void {
|
|
62
|
+
this.currentMode = mode
|
|
63
|
+
if (mode === 'markup') {
|
|
64
|
+
this.markupBtn.classList.add('active')
|
|
65
|
+
this.recordBtn.classList.remove('active')
|
|
66
|
+
this.hintEl.textContent = this.messages.toolbar.hint
|
|
67
|
+
} else {
|
|
68
|
+
this.markupBtn.classList.remove('active')
|
|
69
|
+
this.recordBtn.classList.add('active')
|
|
70
|
+
this.hintEl.textContent = this.messages.toolbar.hintRecord
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getMode(): ToolbarMode {
|
|
75
|
+
return this.currentMode
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
destroy(): void {
|
|
79
|
+
this.el.remove()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { ApiClient } from './client'
|
|
3
|
+
import type { FeedbackPayload } from '../types'
|
|
4
|
+
|
|
5
|
+
const mockPayload: FeedbackPayload = {
|
|
6
|
+
description: 'Test feedback',
|
|
7
|
+
page_url: 'https://example.com',
|
|
8
|
+
user_agent: 'Mozilla/5.0',
|
|
9
|
+
browser: 'Chrome 130',
|
|
10
|
+
os: 'macOS',
|
|
11
|
+
screen_width: 1440,
|
|
12
|
+
screen_height: 900,
|
|
13
|
+
console_errors: [],
|
|
14
|
+
annotations: [],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('ApiClient', () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('sends POST to correct URL with project key header', async () => {
|
|
23
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
24
|
+
new Response(null, { status: 201 }),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const client = new ApiClient('proj_test123', 'https://api.example.com')
|
|
28
|
+
await client.submitFeedback(mockPayload, null)
|
|
29
|
+
|
|
30
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
31
|
+
'https://api.example.com/widget/feedbacks',
|
|
32
|
+
expect.objectContaining({
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'X-Project-Key': 'proj_test123' },
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('includes description in FormData', async () => {
|
|
40
|
+
let capturedBody: FormData | null = null
|
|
41
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
42
|
+
capturedBody = init?.body instanceof FormData ? init.body : null
|
|
43
|
+
return new Response(null, { status: 201 })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const client = new ApiClient('key', 'https://api.example.com')
|
|
47
|
+
await client.submitFeedback(mockPayload, null)
|
|
48
|
+
|
|
49
|
+
expect(capturedBody).not.toBeNull()
|
|
50
|
+
const formData = capturedBody as unknown as FormData
|
|
51
|
+
expect(formData.get('feedback[description]')).toBe('Test feedback')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('uploads screenshot when present', async () => {
|
|
55
|
+
let capturedBody: FormData | null = null
|
|
56
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
57
|
+
capturedBody = init?.body instanceof FormData ? init.body : null
|
|
58
|
+
return new Response(null, { status: 201 })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const screenshot = new Blob(['png'], { type: 'image/png' })
|
|
62
|
+
const client = new ApiClient('key', 'https://api.example.com')
|
|
63
|
+
await client.submitFeedback(mockPayload, screenshot)
|
|
64
|
+
|
|
65
|
+
const formData = capturedBody as unknown as FormData
|
|
66
|
+
expect(formData.get('feedback[screenshot]')).toBeInstanceOf(File)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('throws after retries exhausted', async () => {
|
|
70
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
|
|
71
|
+
|
|
72
|
+
const client = new ApiClient('key', 'https://api.example.com', 0)
|
|
73
|
+
await expect(client.submitFeedback(mockPayload, null)).rejects.toThrow()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FeedbackPayload } from '../types'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API_URL = 'https://makethisbetter.dev/api/v1'
|
|
4
|
+
|
|
5
|
+
export class ApiClient {
|
|
6
|
+
private apiUrl: string
|
|
7
|
+
private projectKey: string
|
|
8
|
+
private retryDelayMs: number
|
|
9
|
+
|
|
10
|
+
constructor(projectKey: string, apiUrl?: string, retryDelayMs = 1000) {
|
|
11
|
+
this.projectKey = projectKey
|
|
12
|
+
this.apiUrl = (apiUrl ?? DEFAULT_API_URL).replace(/\/$/, '')
|
|
13
|
+
this.retryDelayMs = retryDelayMs
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async submitFeedback(payload: FeedbackPayload, screenshot: Blob | null): Promise<void> {
|
|
17
|
+
const form = new FormData()
|
|
18
|
+
form.append('feedback[description]', payload.description)
|
|
19
|
+
form.append('feedback[page_url]', payload.page_url)
|
|
20
|
+
form.append('feedback[user_agent]', payload.user_agent)
|
|
21
|
+
form.append('feedback[browser]', payload.browser)
|
|
22
|
+
form.append('feedback[os]', payload.os)
|
|
23
|
+
form.append('feedback[screen_width]', String(payload.screen_width))
|
|
24
|
+
form.append('feedback[screen_height]', String(payload.screen_height))
|
|
25
|
+
form.append('feedback[console_errors]', JSON.stringify(payload.console_errors))
|
|
26
|
+
form.append('feedback[annotations]', JSON.stringify(payload.annotations))
|
|
27
|
+
|
|
28
|
+
if (payload.target_element) {
|
|
29
|
+
form.append('feedback[target_element]', JSON.stringify(payload.target_element))
|
|
30
|
+
}
|
|
31
|
+
if (payload.user_id) form.append('feedback[user_id]', payload.user_id)
|
|
32
|
+
if (payload.user_email) form.append('feedback[user_email]', payload.user_email)
|
|
33
|
+
if (payload.user_name) form.append('feedback[user_name]', payload.user_name)
|
|
34
|
+
|
|
35
|
+
if (payload.recording_events && payload.recording_events.length > 0) {
|
|
36
|
+
const json = JSON.stringify(payload.recording_events)
|
|
37
|
+
const blob = new Blob([json], { type: 'application/json' })
|
|
38
|
+
form.append('feedback[recording]', blob, 'recording.json')
|
|
39
|
+
form.append('feedback[recording_duration]', String(payload.recording_duration ?? 0))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (screenshot) {
|
|
43
|
+
form.append('feedback[screenshot]', screenshot, 'screenshot.png')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await this.fetchWithRetry(`${this.apiUrl}/widget/feedbacks`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'X-Project-Key': this.projectKey },
|
|
49
|
+
body: form,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async fetchWithRetry(url: string, init: RequestInit, retries = 1): Promise<Response> {
|
|
54
|
+
let res: Response
|
|
55
|
+
try {
|
|
56
|
+
res = await fetch(url, init)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (retries > 0) {
|
|
59
|
+
await delay(this.retryDelayMs)
|
|
60
|
+
return this.fetchWithRetry(url, init, retries - 1)
|
|
61
|
+
}
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (res.ok) return res
|
|
66
|
+
if (res.status >= 500 && retries > 0) {
|
|
67
|
+
await delay(this.retryDelayMs)
|
|
68
|
+
return this.fetchWithRetry(url, init, retries - 1)
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`HTTP ${res.status}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function delay(ms: number): Promise<void> {
|
|
75
|
+
if (ms <= 0) return Promise.resolve()
|
|
76
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { collectPageContext } from './collector'
|
|
3
|
+
|
|
4
|
+
describe('collectPageContext', () => {
|
|
5
|
+
it('returns required fields', () => {
|
|
6
|
+
const ctx = collectPageContext()
|
|
7
|
+
expect(typeof ctx.page_url).toBe('string')
|
|
8
|
+
expect(typeof ctx.user_agent).toBe('string')
|
|
9
|
+
expect(typeof ctx.browser).toBe('string')
|
|
10
|
+
expect(typeof ctx.os).toBe('string')
|
|
11
|
+
expect(typeof ctx.screen_width).toBe('number')
|
|
12
|
+
expect(typeof ctx.screen_height).toBe('number')
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface PageContext {
|
|
2
|
+
page_url: string
|
|
3
|
+
user_agent: string
|
|
4
|
+
browser: string
|
|
5
|
+
os: string
|
|
6
|
+
screen_width: number
|
|
7
|
+
screen_height: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function collectPageContext(): PageContext {
|
|
11
|
+
return {
|
|
12
|
+
page_url: window.location.href,
|
|
13
|
+
user_agent: navigator.userAgent,
|
|
14
|
+
browser: detectBrowser(),
|
|
15
|
+
os: detectOS(),
|
|
16
|
+
screen_width: window.screen.width,
|
|
17
|
+
screen_height: window.screen.height,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function detectBrowser(): string {
|
|
22
|
+
const ua = navigator.userAgent
|
|
23
|
+
if (/Edg\//.test(ua)) return `Edge ${ua.match(/Edg\/(\S+)/)?.[1] ?? ''}`
|
|
24
|
+
if (/OPR\//.test(ua)) return `Opera ${ua.match(/OPR\/(\S+)/)?.[1] ?? ''}`
|
|
25
|
+
if (/Chrome\//.test(ua)) return `Chrome ${ua.match(/Chrome\/(\S+)/)?.[1] ?? ''}`
|
|
26
|
+
if (/Safari\//.test(ua) && /Version\//.test(ua)) return `Safari ${ua.match(/Version\/(\S+)/)?.[1] ?? ''}`
|
|
27
|
+
if (/Firefox\//.test(ua)) return `Firefox ${ua.match(/Firefox\/(\S+)/)?.[1] ?? ''}`
|
|
28
|
+
return 'Unknown'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function detectOS(): string {
|
|
32
|
+
const ua = navigator.userAgent
|
|
33
|
+
if (/Windows NT 10/.test(ua)) return 'Windows 10/11'
|
|
34
|
+
if (/Windows NT/.test(ua)) return 'Windows'
|
|
35
|
+
if (/Mac OS X 10_15|macOS 1[56789]/.test(ua)) return 'macOS'
|
|
36
|
+
if (/Mac OS X/.test(ua)) return 'macOS'
|
|
37
|
+
if (/Android/.test(ua)) return `Android ${ua.match(/Android (\d+)/)?.[1] ?? ''}`
|
|
38
|
+
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS'
|
|
39
|
+
if (/Linux/.test(ua)) return 'Linux'
|
|
40
|
+
return 'Unknown'
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
|
+
import { ConsoleErrorCollector } from './console'
|
|
3
|
+
|
|
4
|
+
describe('ConsoleErrorCollector', () => {
|
|
5
|
+
let collector: ConsoleErrorCollector
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
collector?.stop()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('starts empty', () => {
|
|
12
|
+
collector = new ConsoleErrorCollector()
|
|
13
|
+
collector.start()
|
|
14
|
+
expect(collector.getErrors()).toHaveLength(0)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('captures window.onerror events', () => {
|
|
18
|
+
collector = new ConsoleErrorCollector()
|
|
19
|
+
collector.start()
|
|
20
|
+
|
|
21
|
+
window.onerror?.('TypeError: test error', 'export.js', 42, 1, new Error('test error'))
|
|
22
|
+
|
|
23
|
+
expect(collector.getErrors()).toHaveLength(1)
|
|
24
|
+
expect(collector.getErrors()[0]).toContain('TypeError: test error')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('deduplicates identical errors', () => {
|
|
28
|
+
collector = new ConsoleErrorCollector()
|
|
29
|
+
collector.start()
|
|
30
|
+
|
|
31
|
+
window.onerror?.('Same error', 'file.js', 1, 1, undefined)
|
|
32
|
+
window.onerror?.('Same error', 'file.js', 1, 1, undefined)
|
|
33
|
+
|
|
34
|
+
expect(collector.getErrors()).toHaveLength(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('getErrors returns a copy', () => {
|
|
38
|
+
collector = new ConsoleErrorCollector()
|
|
39
|
+
collector.start()
|
|
40
|
+
const a = collector.getErrors()
|
|
41
|
+
const b = collector.getErrors()
|
|
42
|
+
expect(a).not.toBe(b)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { errorSource, type ErrorSourceEvent } from './error-source'
|
|
2
|
+
|
|
3
|
+
const MAX_CONSOLE_ERRORS = 20
|
|
4
|
+
|
|
5
|
+
export class ConsoleErrorCollector {
|
|
6
|
+
private errors: string[] = []
|
|
7
|
+
private unsubscribe: (() => void) | null = null
|
|
8
|
+
|
|
9
|
+
start(): void {
|
|
10
|
+
if (this.unsubscribe) return
|
|
11
|
+
this.unsubscribe = errorSource.subscribe((e) => this.record(e))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private record(e: ErrorSourceEvent): void {
|
|
15
|
+
const location = e.line != null ? ` (${e.line}:${e.col ?? 0})` : ''
|
|
16
|
+
this.addError(`${e.message}${location}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private addError(msg: string): void {
|
|
20
|
+
if (this.errors.length >= MAX_CONSOLE_ERRORS) return
|
|
21
|
+
if (!this.errors.includes(msg)) {
|
|
22
|
+
this.errors.push(msg)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getErrors(): string[] {
|
|
27
|
+
return [...this.errors]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
stop(): void {
|
|
31
|
+
this.unsubscribe?.()
|
|
32
|
+
this.unsubscribe = null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { errorSource, type ErrorSourceEvent } from './error-source'
|
|
3
|
+
|
|
4
|
+
describe('ErrorSource', () => {
|
|
5
|
+
let unsubs: (() => void)[]
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
unsubs = []
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
unsubs.forEach((fn) => fn())
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('subscribe returns an unsubscribe function', () => {
|
|
16
|
+
const unsub = errorSource.subscribe(() => {})
|
|
17
|
+
unsubs.push(unsub)
|
|
18
|
+
expect(typeof unsub).toBe('function')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('delivers window.onerror events to a subscriber', () => {
|
|
22
|
+
const received: ErrorSourceEvent[] = []
|
|
23
|
+
const unsub = errorSource.subscribe((e) => received.push(e))
|
|
24
|
+
unsubs.push(unsub)
|
|
25
|
+
|
|
26
|
+
window.onerror?.('Test error', 'test.js', 10, 5, new Error('boom'))
|
|
27
|
+
|
|
28
|
+
expect(received).toHaveLength(1)
|
|
29
|
+
expect(received[0].message).toBe('Test error')
|
|
30
|
+
expect(received[0].source).toBe('test.js')
|
|
31
|
+
expect(received[0].line).toBe(10)
|
|
32
|
+
expect(received[0].col).toBe(5)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('delivers events to multiple subscribers', () => {
|
|
36
|
+
const a: ErrorSourceEvent[] = []
|
|
37
|
+
const b: ErrorSourceEvent[] = []
|
|
38
|
+
const unsubA = errorSource.subscribe((e) => a.push(e))
|
|
39
|
+
const unsubB = errorSource.subscribe((e) => b.push(e))
|
|
40
|
+
unsubs.push(unsubA, unsubB)
|
|
41
|
+
|
|
42
|
+
window.onerror?.('multi', 'file.js', 1, 1, undefined)
|
|
43
|
+
|
|
44
|
+
expect(a).toHaveLength(1)
|
|
45
|
+
expect(b).toHaveLength(1)
|
|
46
|
+
expect(a[0].message).toBe('multi')
|
|
47
|
+
expect(b[0].message).toBe('multi')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('unsubscribing stops delivery to that listener', () => {
|
|
51
|
+
const received: ErrorSourceEvent[] = []
|
|
52
|
+
const unsub = errorSource.subscribe((e) => received.push(e))
|
|
53
|
+
|
|
54
|
+
window.onerror?.('before unsub', 'file.js', 1, 1, undefined)
|
|
55
|
+
expect(received).toHaveLength(1)
|
|
56
|
+
|
|
57
|
+
unsub()
|
|
58
|
+
|
|
59
|
+
window.onerror?.('after unsub', 'file.js', 2, 1, undefined)
|
|
60
|
+
expect(received).toHaveLength(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('auto-unhooks window.onerror when last subscriber leaves', () => {
|
|
64
|
+
const onErrorBefore = window.onerror
|
|
65
|
+
|
|
66
|
+
const unsub1 = errorSource.subscribe(() => {})
|
|
67
|
+
const unsub2 = errorSource.subscribe(() => {})
|
|
68
|
+
|
|
69
|
+
expect(window.onerror).not.toBe(onErrorBefore)
|
|
70
|
+
|
|
71
|
+
unsub1()
|
|
72
|
+
expect(window.onerror).not.toBe(onErrorBefore)
|
|
73
|
+
|
|
74
|
+
unsub2()
|
|
75
|
+
expect(window.onerror).toBe(onErrorBefore)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('captures unhandled promise rejections', () => {
|
|
79
|
+
const received: ErrorSourceEvent[] = []
|
|
80
|
+
const unsub = errorSource.subscribe((e) => received.push(e))
|
|
81
|
+
unsubs.push(unsub)
|
|
82
|
+
|
|
83
|
+
const event = new Event('unhandledrejection') as PromiseRejectionEvent
|
|
84
|
+
Object.defineProperty(event, 'reason', { value: new Error('rejected') })
|
|
85
|
+
window.dispatchEvent(event)
|
|
86
|
+
|
|
87
|
+
expect(received).toHaveLength(1)
|
|
88
|
+
expect(received[0].message).toBe('Unhandled rejection: rejected')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('formats non-Error rejection reasons as strings', () => {
|
|
92
|
+
const received: ErrorSourceEvent[] = []
|
|
93
|
+
const unsub = errorSource.subscribe((e) => received.push(e))
|
|
94
|
+
unsubs.push(unsub)
|
|
95
|
+
|
|
96
|
+
const event = new Event('unhandledrejection') as PromiseRejectionEvent
|
|
97
|
+
Object.defineProperty(event, 'reason', { value: 'string reason' })
|
|
98
|
+
window.dispatchEvent(event)
|
|
99
|
+
|
|
100
|
+
expect(received).toHaveLength(1)
|
|
101
|
+
expect(received[0].message).toBe('Unhandled rejection: string reason')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('chains to original window.onerror handler', () => {
|
|
105
|
+
const originalCalled = vi.fn()
|
|
106
|
+
window.onerror = originalCalled
|
|
107
|
+
|
|
108
|
+
const unsub = errorSource.subscribe(() => {})
|
|
109
|
+
unsubs.push(unsub)
|
|
110
|
+
|
|
111
|
+
window.onerror?.('chained', 'file.js', 1, 1, undefined)
|
|
112
|
+
|
|
113
|
+
expect(originalCalled).toHaveBeenCalledOnce()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface ErrorSourceEvent {
|
|
2
|
+
message: string
|
|
3
|
+
source?: string
|
|
4
|
+
line?: number
|
|
5
|
+
col?: number
|
|
6
|
+
error?: unknown
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type ErrorListener = (event: ErrorSourceEvent) => void
|
|
10
|
+
|
|
11
|
+
class ErrorSource {
|
|
12
|
+
private listeners = new Set<ErrorListener>()
|
|
13
|
+
private originalOnError: OnErrorEventHandler | null = null
|
|
14
|
+
private rejectionHandler: ((e: PromiseRejectionEvent) => void) | null = null
|
|
15
|
+
private hooked = false
|
|
16
|
+
|
|
17
|
+
subscribe(listener: ErrorListener): () => void {
|
|
18
|
+
this.listeners.add(listener)
|
|
19
|
+
this.hook()
|
|
20
|
+
return () => {
|
|
21
|
+
this.listeners.delete(listener)
|
|
22
|
+
if (this.listeners.size === 0) this.unhook()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private hook(): void {
|
|
27
|
+
if (this.hooked) return
|
|
28
|
+
this.hooked = true
|
|
29
|
+
|
|
30
|
+
this.originalOnError = window.onerror
|
|
31
|
+
window.onerror = (msg, source, line, col, error) => {
|
|
32
|
+
this.emit({ message: String(msg), source, line, col, error })
|
|
33
|
+
if (this.originalOnError) {
|
|
34
|
+
return this.originalOnError.call(window, msg, source, line, col, error)
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.rejectionHandler = (e: PromiseRejectionEvent) => {
|
|
40
|
+
const reason = e.reason instanceof Error ? e.reason.message : String(e.reason)
|
|
41
|
+
this.emit({ message: `Unhandled rejection: ${reason}` })
|
|
42
|
+
}
|
|
43
|
+
window.addEventListener('unhandledrejection', this.rejectionHandler)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private unhook(): void {
|
|
47
|
+
if (!this.hooked) return
|
|
48
|
+
this.hooked = false
|
|
49
|
+
window.onerror = this.originalOnError
|
|
50
|
+
this.originalOnError = null
|
|
51
|
+
if (this.rejectionHandler) {
|
|
52
|
+
window.removeEventListener('unhandledrejection', this.rejectionHandler)
|
|
53
|
+
this.rejectionHandler = null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private emit(event: ErrorSourceEvent): void {
|
|
58
|
+
for (const listener of this.listeners) listener(event)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const errorSource = new ErrorSource()
|