Skip to content

Commit d0420d5

Browse files
feat(helpscout,#84): expose missing Beacon commands, events, and init config
Vendor-doc audit fixes for HelpScout Beacon: Methods (10 new): search, article (with type sidebar/modal), sessionData (20 conversation attributes), config (runtime reconfiguration), reset (clear contact-form fields without logging out), toggle, askQuestion (AI Answers prefill), showMessage (with delay/force), info (sync state read), once (one-shot listener). prefill() also gains attachments support, and shutdown() forwards clearMessages. Events (5 added to BeaconEvent union): search, message-clicked, message-closed, message-triggered, plus existing entries kept for backward compatibility (chat-ended retained even though not in current public docs). Init config: HelpScoutLoadOptions now accepts an optional `config` object typed as BeaconConfig (docsEnabled/messagingEnabled/color/mode/display/ messaging/labels/etc.) merged into Beacon("init", { beaconId, ...config }). Bumps helpscout bundle budget 6KB → 7KB (raw 6625B; gzip ≈ 1/3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d4d5f1 commit d0420d5

3 files changed

Lines changed: 315 additions & 16 deletions

File tree

scripts/bundle-budget.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const DIST = resolve(process.cwd(), "dist")
1212
const BUDGETS = [
1313
{ glob: /^providers\/chatwoot\.mjs$/, max: 10240, label: "provider:chatwoot" },
1414
{ glob: /^providers\/intercom\.mjs$/, max: 9216, label: "provider:intercom" },
15+
{ glob: /^providers\/helpscout\.mjs$/, max: 7168, label: "provider:helpscout" },
1516
{ glob: /^providers\/.+\.mjs$/, max: 6144, label: "provider" },
1617
{ glob: /^index\.mjs$/, max: 4096, label: "core" },
1718
{ glob: /^facade\.mjs$/, max: 3072, label: "facade" },

src/providers/helpscout.ts

Lines changed: 149 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,46 @@ const lifecycle = createLifecycle()
2727
let readyPromise: Promise<void> | undefined
2828
let readyResolve: (() => void) | undefined
2929

30+
export type BeaconMode = "selfService" | "neutral" | "askFirst"
31+
32+
export interface BeaconDisplayConfig {
33+
style?: "icon" | "text" | "iconAndText"
34+
text?: string
35+
position?: "left" | "right"
36+
zIndex?: number
37+
horizontalOffset?: number | string
38+
verticalOffset?: number | string
39+
}
40+
41+
export interface BeaconMessagingConfig {
42+
chatEnabled?: boolean
43+
contactForm?: {
44+
customFieldsEnabled?: boolean
45+
showName?: boolean
46+
showSubject?: boolean
47+
allowAttachments?: boolean
48+
}
49+
}
50+
51+
export interface BeaconConfig {
52+
docsEnabled?: boolean
53+
messagingEnabled?: boolean
54+
enableFabAnimation?: boolean
55+
enablePreviousMessages?: boolean
56+
enableSounds?: boolean
57+
color?: string
58+
mode?: BeaconMode
59+
hideAvatars?: boolean
60+
hideFABOnMobile?: boolean
61+
display?: BeaconDisplayConfig
62+
messaging?: BeaconMessagingConfig
63+
labels?: Record<string, string>
64+
}
65+
3066
export interface HelpScoutLoadOptions extends LoadOptions {
3167
beaconId: string
68+
/** Optional Beacon `init` config object (display, color, mode, labels, etc.). */
69+
config?: BeaconConfig
3270
}
3371

3472
export async function load(options: HelpScoutLoadOptions): Promise<void> {
@@ -61,7 +99,10 @@ export async function load(options: HelpScoutLoadOptions): Promise<void> {
6199
for (let i = 0; i < 80; i++) {
62100
const beacon = w().Beacon
63101
if (typeof beacon === "function") {
64-
beacon("init", options.beaconId)
102+
const initArg = options.config
103+
? { beaconId: options.beaconId, ...options.config }
104+
: options.beaconId
105+
beacon("init", initArg)
65106
beacon("on", "ready", () => {
66107
queue.ready(beacon)
67108
readyResolve?.()
@@ -122,33 +163,109 @@ export function suggest(articleIds: string[]): Promise<void> {
122163
return queue.enqueue((Beacon) => Beacon("suggest", ids))
123164
}
124165

125-
export function navigate(path: string): Promise<void> {
166+
export type BeaconRoute =
167+
| "/ask/"
168+
| "/ask/message/"
169+
| "/ask/chat/"
170+
| "/answers/"
171+
| "/ai-answers/"
172+
| "/previous-messages/"
173+
| (string & {})
174+
175+
export function navigate(path: BeaconRoute): Promise<void> {
126176
if (!isBrowser()) return Promise.resolve()
127177
return queue.enqueue((Beacon) => Beacon("navigate", path))
128178
}
129179

180+
export function search(query: string): Promise<void> {
181+
if (!isBrowser()) return Promise.resolve()
182+
return queue.enqueue((Beacon) => Beacon("search", query))
183+
}
184+
185+
export function article(
186+
articleId: string,
187+
options?: { type?: "sidebar" | "modal" },
188+
): Promise<void> {
189+
if (!isBrowser()) return Promise.resolve()
190+
return queue.enqueue((Beacon) =>
191+
options ? Beacon("article", articleId, options) : Beacon("article", articleId),
192+
)
193+
}
194+
195+
export function sessionData(data: Record<string, string>): Promise<void> {
196+
if (!isBrowser()) return Promise.resolve()
197+
return queue.enqueue((Beacon) => Beacon("session-data", data))
198+
}
199+
200+
export function config(next: BeaconConfig): Promise<void> {
201+
if (!isBrowser()) return Promise.resolve()
202+
return queue.enqueue((Beacon) => Beacon("config", next))
203+
}
204+
205+
export function reset(): Promise<void> {
206+
if (!isBrowser()) return Promise.resolve()
207+
return queue.enqueue((Beacon) => Beacon("reset"))
208+
}
209+
210+
export function toggle(): Promise<void> {
211+
if (!isBrowser()) return Promise.resolve()
212+
return queue.enqueue((Beacon) => Beacon("toggle"))
213+
}
214+
215+
export function askQuestion(question: string): Promise<void> {
216+
if (!isBrowser()) return Promise.resolve()
217+
return queue.enqueue((Beacon) => Beacon("ask-question", question))
218+
}
219+
220+
export function showMessage(
221+
id: string,
222+
options?: { delay?: number; force?: boolean },
223+
): Promise<void> {
224+
if (!isBrowser()) return Promise.resolve()
225+
return queue.enqueue((Beacon) =>
226+
options ? Beacon("show-message", id, options) : Beacon("show-message", id),
227+
)
228+
}
229+
230+
export function info(): Promise<unknown> {
231+
if (!isBrowser()) return Promise.resolve(undefined)
232+
return new Promise((resolve) => {
233+
queue
234+
.enqueue((Beacon) => {
235+
const result = (Beacon as unknown as (cmd: string) => unknown)("info")
236+
resolve(result)
237+
})
238+
.catch(() => resolve(undefined))
239+
})
240+
}
241+
130242
export function prefill(payload: {
131243
name?: string
132244
email?: string
133245
subject?: string
134246
text?: string
135247
fields?: Array<{ id: number; value: string }>
248+
/** Up to 3 file attachments. */
249+
attachments?: Array<{ url: string; filename: string }>
136250
}): Promise<void> {
137251
if (!isBrowser()) return Promise.resolve()
138252
return queue.enqueue((Beacon) => Beacon("prefill", payload))
139253
}
140254

141-
export function on(
142-
event:
143-
| "ready"
144-
| "open"
145-
| "close"
146-
| "article-viewed"
147-
| "email-sent"
148-
| "chat-started"
149-
| "chat-ended",
150-
listener: (payload: unknown) => void,
151-
): () => void {
255+
export type BeaconEvent =
256+
| "ready"
257+
| "open"
258+
| "close"
259+
| "article-viewed"
260+
| "email-sent"
261+
| "chat-started"
262+
| "chat-ended"
263+
| "search"
264+
| "message-clicked"
265+
| "message-closed"
266+
| "message-triggered"
267+
268+
export function on(event: BeaconEvent, listener: (payload: unknown) => void): () => void {
152269
if (!isBrowser()) return () => {}
153270
let removed = false
154271
queue.enqueue((Beacon) => {
@@ -160,6 +277,18 @@ export function on(
160277
}
161278
}
162279

280+
export function once(event: BeaconEvent, listener: (payload: unknown) => void): () => void {
281+
if (!isBrowser()) return () => {}
282+
let removed = false
283+
queue.enqueue((Beacon) => {
284+
if (!removed) Beacon("once", event, listener)
285+
})
286+
return () => {
287+
removed = true
288+
queue.enqueue((Beacon) => Beacon("off", event, listener))
289+
}
290+
}
291+
163292
export function show(): Promise<void> {
164293
if (!isBrowser()) return Promise.resolve()
165294
return queue.enqueue((Beacon) => Beacon("open"))
@@ -170,11 +299,15 @@ export function hide(): Promise<void> {
170299
return queue.enqueue((Beacon) => Beacon("close"))
171300
}
172301

173-
export function shutdown(opts?: { endActiveChat?: boolean }): Promise<void> {
302+
export function shutdown(opts?: {
303+
endActiveChat?: boolean
304+
clearMessages?: boolean
305+
}): Promise<void> {
174306
if (!isBrowser()) return Promise.resolve()
175-
const endActiveChat = opts?.endActiveChat ?? true
307+
const payload: Record<string, unknown> = { endActiveChat: opts?.endActiveChat ?? true }
308+
if (opts?.clearMessages !== undefined) payload["clearMessages"] = opts.clearMessages
176309
return queue
177-
.enqueue((Beacon) => Beacon("logout", { endActiveChat }))
310+
.enqueue((Beacon) => Beacon("logout", payload))
178311
.then(() => {
179312
store.reset()
180313
lifecycle.transition("shutdown")

test/helpscout.browser.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// @vitest-environment jsdom
2+
import { beforeEach, describe, expect, it } from "vitest"
3+
4+
interface BeaconCall {
5+
method: string
6+
args: unknown[]
7+
}
8+
9+
function recorderBeacon(calls: BeaconCall[]): (method: string, ...args: unknown[]) => unknown {
10+
return (method, ...args) => {
11+
calls.push({ method, args })
12+
if (method === "on" && args[0] === "ready" && typeof args[1] === "function") {
13+
;(args[1] as () => void)()
14+
}
15+
if (method === "info") return { open: false, beaconId: "test" }
16+
return undefined
17+
}
18+
}
19+
20+
async function bootHelpScout(
21+
calls: BeaconCall[],
22+
options?: Partial<import("../src/providers/helpscout.ts").HelpScoutLoadOptions>,
23+
): Promise<typeof import("../src/providers/helpscout.ts")> {
24+
const helpscout = await import("../src/providers/helpscout.ts")
25+
// biome-ignore lint/suspicious/noExplicitAny: test shim
26+
;(globalThis as any).Beacon = recorderBeacon(calls)
27+
const loadPromise = helpscout.load({ beaconId: "bid_xyz", ...options })
28+
await new Promise((r) => setTimeout(r, 0))
29+
const script = document.getElementById("ahize-helpscout") as HTMLScriptElement
30+
expect(script).toBeTruthy()
31+
script.dispatchEvent(new Event("load"))
32+
await loadPromise
33+
return helpscout
34+
}
35+
36+
describe("helpscout (browser) — vendor-doc audit fixes (#84)", () => {
37+
beforeEach(() => {
38+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
39+
delete (globalThis as any).Beacon
40+
const scripts = document.querySelectorAll("script")
41+
for (let i = 0; i < scripts.length; i++) {
42+
;(scripts[i] as { remove(): void } | undefined)?.remove()
43+
}
44+
})
45+
46+
it("init forwards a config object when provided", async () => {
47+
const calls: BeaconCall[] = []
48+
const helpscout = await bootHelpScout(calls, {
49+
config: {
50+
color: "#abcdef",
51+
mode: "askFirst",
52+
display: { style: "iconAndText", position: "left", zIndex: 99 },
53+
labels: { greeting: "Selam" },
54+
},
55+
})
56+
const initCall = calls.find((c) => c.method === "init")
57+
expect(initCall).toBeTruthy()
58+
expect(initCall?.args[0]).toMatchObject({
59+
beaconId: "bid_xyz",
60+
color: "#abcdef",
61+
mode: "askFirst",
62+
display: { style: "iconAndText", position: "left", zIndex: 99 },
63+
labels: { greeting: "Selam" },
64+
})
65+
await helpscout.destroy()
66+
})
67+
68+
it("init falls back to bare beaconId when no config", async () => {
69+
const calls: BeaconCall[] = []
70+
const helpscout = await bootHelpScout(calls)
71+
const initCall = calls.find((c) => c.method === "init")
72+
expect(initCall?.args).toEqual(["bid_xyz"])
73+
await helpscout.destroy()
74+
})
75+
76+
it("forwards search/article/sessionData/config/reset/toggle/askQuestion/showMessage", async () => {
77+
const calls: BeaconCall[] = []
78+
const helpscout = await bootHelpScout(calls)
79+
80+
await helpscout.search("how to refund")
81+
await helpscout.article("art_1", { type: "modal" })
82+
await helpscout.sessionData({ plan: "pro", country: "TR" })
83+
await helpscout.config({ color: "#000" })
84+
await helpscout.reset()
85+
await helpscout.toggle()
86+
await helpscout.askQuestion("Where is my order?")
87+
await helpscout.showMessage("msg_1", { delay: 500, force: true })
88+
89+
const methods = calls.map((c) => c.method)
90+
expect(methods).toContain("search")
91+
expect(methods).toContain("article")
92+
expect(methods).toContain("session-data")
93+
expect(methods).toContain("config")
94+
expect(methods).toContain("reset")
95+
expect(methods).toContain("toggle")
96+
expect(methods).toContain("ask-question")
97+
expect(methods).toContain("show-message")
98+
99+
expect(calls.find((c) => c.method === "article")?.args).toEqual(["art_1", { type: "modal" }])
100+
expect(calls.find((c) => c.method === "show-message")?.args).toEqual([
101+
"msg_1",
102+
{ delay: 500, force: true },
103+
])
104+
await helpscout.destroy()
105+
})
106+
107+
it("info() returns the Beacon synchronous result", async () => {
108+
const calls: BeaconCall[] = []
109+
const helpscout = await bootHelpScout(calls)
110+
const result = await helpscout.info()
111+
expect(result).toEqual({ open: false, beaconId: "test" })
112+
await helpscout.destroy()
113+
})
114+
115+
it("on('search'|'message-clicked'|'message-closed'|'message-triggered') subscribes via Beacon", async () => {
116+
const calls: BeaconCall[] = []
117+
const helpscout = await bootHelpScout(calls)
118+
119+
helpscout.on("search", () => {})
120+
helpscout.on("message-clicked", () => {})
121+
helpscout.on("message-closed", () => {})
122+
helpscout.on("message-triggered", () => {})
123+
await new Promise((r) => setTimeout(r, 0))
124+
125+
const subscribed = calls.filter((c) => c.method === "on").map((c) => c.args[0])
126+
expect(subscribed).toContain("search")
127+
expect(subscribed).toContain("message-clicked")
128+
expect(subscribed).toContain("message-closed")
129+
expect(subscribed).toContain("message-triggered")
130+
await helpscout.destroy()
131+
})
132+
133+
it("once() registers a one-shot Beacon listener", async () => {
134+
const calls: BeaconCall[] = []
135+
const helpscout = await bootHelpScout(calls)
136+
helpscout.once("open", () => {})
137+
await new Promise((r) => setTimeout(r, 0))
138+
const onceCall = calls.find((c) => c.method === "once")
139+
expect(onceCall?.args[0]).toBe("open")
140+
await helpscout.destroy()
141+
})
142+
143+
it("shutdown forwards clearMessages when provided", async () => {
144+
const calls: BeaconCall[] = []
145+
const helpscout = await bootHelpScout(calls)
146+
await helpscout.shutdown({ endActiveChat: false, clearMessages: true })
147+
const logoutCall = calls.find((c) => c.method === "logout")
148+
expect(logoutCall?.args[0]).toEqual({ endActiveChat: false, clearMessages: true })
149+
await helpscout.destroy()
150+
})
151+
152+
it("prefill accepts attachments", async () => {
153+
const calls: BeaconCall[] = []
154+
const helpscout = await bootHelpScout(calls)
155+
await helpscout.prefill({
156+
name: "x",
157+
attachments: [{ url: "https://x/a.png", filename: "a.png" }],
158+
})
159+
const prefill = calls.find((c) => c.method === "prefill")
160+
expect(prefill?.args[0]).toMatchObject({
161+
attachments: [{ url: "https://x/a.png", filename: "a.png" }],
162+
})
163+
await helpscout.destroy()
164+
})
165+
})

0 commit comments

Comments
 (0)