@holdyapp/sdk 1.6.0 → 1.9.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.
Files changed (48) hide show
  1. package/README.md +8 -1
  2. package/dist/changes.d.ts +16 -2
  3. package/dist/changes.d.ts.map +1 -1
  4. package/dist/core.d.ts +1 -0
  5. package/dist/core.d.ts.map +1 -1
  6. package/dist/friction.d.ts +2 -1
  7. package/dist/friction.d.ts.map +1 -1
  8. package/dist/holdy.js +25 -4
  9. package/dist/holdy.js.map +4 -4
  10. package/dist/index.cjs +420 -36
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.mjs +420 -36
  13. package/dist/index.mjs.map +4 -4
  14. package/dist/server.cjs +3 -0
  15. package/dist/server.cjs.map +2 -2
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +2 -2
  18. package/dist/skeleton.d.ts +3 -1
  19. package/dist/skeleton.d.ts.map +1 -1
  20. package/dist/theme.d.ts +43 -0
  21. package/dist/theme.d.ts.map +1 -0
  22. package/dist/tip-css.d.ts +9 -0
  23. package/dist/tip-css.d.ts.map +1 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/web-context.d.ts +1 -0
  27. package/dist/web-context.d.ts.map +1 -1
  28. package/dist/web.d.ts +2 -1
  29. package/dist/web.d.ts.map +1 -1
  30. package/dist/web.mjs +420 -36
  31. package/dist/web.mjs.map +4 -4
  32. package/package.json +1 -1
  33. package/src/changes.dom.test.ts +221 -0
  34. package/src/changes.test.ts +38 -0
  35. package/src/changes.ts +299 -26
  36. package/src/core.test.ts +15 -0
  37. package/src/core.ts +3 -0
  38. package/src/friction.dom.test.ts +54 -0
  39. package/src/friction.test.ts +43 -0
  40. package/src/friction.ts +44 -7
  41. package/src/skeleton.ts +12 -3
  42. package/src/theme.test.ts +118 -0
  43. package/src/theme.ts +120 -0
  44. package/src/tip-css.ts +32 -0
  45. package/src/types.ts +6 -0
  46. package/src/web-context.test.ts +18 -0
  47. package/src/web-context.ts +8 -0
  48. package/src/web.ts +8 -2
@@ -0,0 +1,221 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { changeArm } from './arm'
4
+ import { installProductChanges, resolvePreviewId, type ProductChange, type ProductChangesHandle } from './changes'
5
+
6
+ const ID = '11111111-1111-4111-8111-111111111111'
7
+ const OTHER = '22222222-2222-4222-8222-222222222222'
8
+
9
+ const change = (over: Partial<ProductChange> = {}): ProductChange => ({
10
+ id: ID,
11
+ kind: 'guidance',
12
+ path: '/setup',
13
+ anchor: { kind: 'page' },
14
+ expected_text: null,
15
+ content: { text: 'Conecte a fonte antes de seguir.' },
16
+ holdout_pct: 50,
17
+ ...over,
18
+ })
19
+
20
+ function accountIn(arm: 'change' | 'holdout', id = ID): string {
21
+ for (let i = 0; i < 500; i += 1) if (changeArm(id, `conta-${i}`, 50) === arm) return `conta-${i}`
22
+ throw new Error('sem conta no grupo')
23
+ }
24
+
25
+ let shadows: ShadowRoot[] = []
26
+ let handle: ProductChangesHandle | null = null
27
+ let requested: string[] = []
28
+ const track = vi.fn()
29
+
30
+ function serve(changes: ProductChange[]): void {
31
+ requested = []
32
+ vi.stubGlobal(
33
+ 'fetch',
34
+ vi.fn(async (url: string) => {
35
+ requested.push(String(url))
36
+ return { ok: true, json: async () => ({ changes, done: [] }) }
37
+ }),
38
+ )
39
+ }
40
+
41
+ async function install(account: string | null): Promise<void> {
42
+ handle = installProductChanges({ baseUrl: 'https://api.test/api/v1', apiKey: 'k', getAccountKey: () => account, track })
43
+ await vi.advanceTimersByTimeAsync(400)
44
+ }
45
+
46
+ const card = (): Element | null => shadows.flatMap((root) => [...root.querySelectorAll('.tip')])[0] ?? null
47
+ const tracked = (name: string) => track.mock.calls.filter((call) => call[0] === name)
48
+
49
+ beforeEach(() => {
50
+ vi.useFakeTimers()
51
+ shadows = []
52
+ track.mockClear()
53
+ const attach = Element.prototype.attachShadow
54
+ vi.spyOn(Element.prototype, 'attachShadow').mockImplementation(function (this: Element, init: ShadowRootInit) {
55
+ const root = attach.call(this, { ...init, mode: 'open' })
56
+ shadows.push(root)
57
+ return root
58
+ })
59
+ window.localStorage.clear()
60
+ window.sessionStorage.clear()
61
+ window.history.replaceState(null, '', '/setup')
62
+ document.body.innerHTML = '<main><h1>Setup</h1><button data-testid="save">Salvar</button></main>'
63
+ })
64
+
65
+ afterEach(() => {
66
+ handle?.teardown()
67
+ handle = null
68
+ vi.restoreAllMocks()
69
+ vi.unstubAllGlobals()
70
+ vi.useRealTimers()
71
+ })
72
+
73
+ describe('a mudança aparece no produto', () => {
74
+ it('conta do grupo da mudança vê a dica no canto e a exposição sai uma vez', async () => {
75
+ serve([change()])
76
+ await install(accountIn('change'))
77
+ expect(card()?.textContent).toContain('Conecte a fonte antes de seguir.')
78
+ expect(tracked('$change_exposure')).toEqual([['$change_exposure', { change_id: ID, arm: 'change', kind: 'guidance' }]])
79
+ handle?.onNavigate()
80
+ await vi.advanceTimersByTimeAsync(400)
81
+ expect(tracked('$change_exposure')).toHaveLength(1)
82
+ })
83
+
84
+ it('conta de fora não vê nada, mas a exposição conta no grupo de fora', async () => {
85
+ serve([change()])
86
+ await install(accountIn('holdout'))
87
+ expect(card()).toBeNull()
88
+ expect(tracked('$change_exposure')[0][1]).toMatchObject({ arm: 'holdout' })
89
+ })
90
+
91
+ it('sem conta identificada nada aparece e nada é medido', async () => {
92
+ serve([change()])
93
+ await install(null)
94
+ expect(card()).toBeNull()
95
+ expect(track).not.toHaveBeenCalled()
96
+ })
97
+
98
+ it('fora da página do molde a dica não aparece', async () => {
99
+ window.history.replaceState(null, '', '/billing')
100
+ serve([change()])
101
+ await install(accountIn('change'))
102
+ expect(card()).toBeNull()
103
+ expect(track).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('dica ancorada em elemento só aparece quando o elemento existe', async () => {
107
+ serve([change({ anchor: { kind: 'selector', value: '[data-testid="invite"]' } })])
108
+ await install(accountIn('change'))
109
+ expect(card()).toBeNull()
110
+ expect(track).not.toHaveBeenCalled()
111
+ document.body.insertAdjacentHTML('beforeend', '<button data-testid="invite">Convidar</button>')
112
+ await vi.advanceTimersByTimeAsync(400)
113
+ expect(card()).not.toBeNull()
114
+ expect(tracked('$change_exposure')).toHaveLength(1)
115
+ })
116
+
117
+ it('troca de texto troca só o texto esperado e devolve o original ao desligar', async () => {
118
+ serve([change({ kind: 'copy', anchor: { kind: 'selector', value: '[data-testid="save"]' }, expected_text: 'Salvar', content: { text: 'Salvar e continuar' } })])
119
+ await install(accountIn('change'))
120
+ const button = document.querySelector('[data-testid="save"]')
121
+ expect(button?.textContent).toBe('Salvar e continuar')
122
+ expect(tracked('$change_exposure')).toHaveLength(1)
123
+ handle?.teardown()
124
+ handle = null
125
+ expect(button?.textContent).toBe('Salvar')
126
+ })
127
+
128
+ it('troca de texto não mexe quando o texto atual já é outro', async () => {
129
+ document.querySelector('[data-testid="save"]')!.textContent = 'Gravar'
130
+ serve([change({ kind: 'copy', anchor: { kind: 'selector', value: '[data-testid="save"]' }, expected_text: 'Salvar', content: { text: 'Salvar e continuar' } })])
131
+ await install(accountIn('change'))
132
+ expect(document.querySelector('[data-testid="save"]')?.textContent).toBe('Gravar')
133
+ expect(track).not.toHaveBeenCalled()
134
+ })
135
+
136
+ it('fechar a dica registra e ela não volta', async () => {
137
+ serve([change()])
138
+ await install(accountIn('change'))
139
+ ;(card()?.querySelector('button') as HTMLButtonElement).click()
140
+ expect(tracked('$change_dismissed')).toHaveLength(1)
141
+ handle?.onNavigate()
142
+ await vi.advanceTimersByTimeAsync(400)
143
+ expect(card()).toBeNull()
144
+ })
145
+
146
+ it('pergunta respondida vira $voice ligada ao plano', async () => {
147
+ serve([change({ kind: 'question', plan_id: 'plano-1', content: { text: 'O que te impediu de continuar?' } })])
148
+ await install(accountIn('change'))
149
+ const input = card()?.querySelector('textarea') as HTMLTextAreaElement
150
+ const send = card()?.querySelector('.send') as HTMLButtonElement
151
+ expect(send.disabled).toBe(true)
152
+ input.value = 'Não achei o botão'
153
+ input.dispatchEvent(new Event('input'))
154
+ send.click()
155
+ expect(tracked('$voice')[0][1]).toMatchObject({ text: 'Não achei o botão', action_plan_id: 'plano-1', change_id: ID, path: '/setup' })
156
+ })
157
+ })
158
+
159
+ describe('prévia', () => {
160
+ it('quem abre com holdy_preview vê a mudança mesmo caindo no grupo de fora, e nada é medido', async () => {
161
+ window.history.replaceState(null, '', `/setup?holdy_preview=${ID}`)
162
+ serve([change()])
163
+ await install(accountIn('holdout'))
164
+ expect(card()?.textContent).toContain('Conecte a fonte antes de seguir.')
165
+ expect(card()?.querySelector('.preview')?.textContent).toBe('Prévia, só você vê')
166
+ expect(track).not.toHaveBeenCalled()
167
+ expect(requested[0]).toContain(`preview=${ID}`)
168
+ })
169
+
170
+ it('prévia aparece sem conta identificada', async () => {
171
+ window.history.replaceState(null, '', `/setup?holdy_preview=${ID}`)
172
+ serve([change()])
173
+ await install(null)
174
+ expect(card()).not.toBeNull()
175
+ expect(track).not.toHaveBeenCalled()
176
+ })
177
+
178
+ it('a prévia vale só pra mudança pedida: as outras seguem o sorteio e a medida', async () => {
179
+ window.history.replaceState(null, '', `/setup?holdy_preview=${ID}`)
180
+ const account = accountIn('holdout', OTHER)
181
+ serve([change(), change({ id: OTHER, content: { text: 'Outra dica.' } })])
182
+ await install(account)
183
+ expect(tracked('$change_exposure')).toEqual([['$change_exposure', { change_id: OTHER, arm: 'holdout', kind: 'guidance' }]])
184
+ })
185
+
186
+ it('a prévia continua ligada ao navegar na mesma aba e desliga com valor inválido', async () => {
187
+ window.history.replaceState(null, '', `/?holdy_preview=${ID}`)
188
+ serve([change()])
189
+ await install(accountIn('holdout'))
190
+ expect(card()).toBeNull()
191
+ window.history.pushState(null, '', '/setup')
192
+ handle?.onNavigate()
193
+ await vi.advanceTimersByTimeAsync(400)
194
+ expect(card()).not.toBeNull()
195
+ window.history.pushState(null, '', '/setup?holdy_preview=off')
196
+ handle?.onNavigate()
197
+ await vi.advanceTimersByTimeAsync(400)
198
+ expect(card()).toBeNull()
199
+ })
200
+
201
+ it('responder a pergunta na prévia não grava resposta', async () => {
202
+ window.history.replaceState(null, '', `/setup?holdy_preview=${ID}`)
203
+ serve([change({ kind: 'question', plan_id: 'plano-1', content: { text: 'O que te impediu de continuar?' } })])
204
+ await install(accountIn('change'))
205
+ const input = card()?.querySelector('textarea') as HTMLTextAreaElement
206
+ input.value = 'teste'
207
+ input.dispatchEvent(new Event('input'))
208
+ ;(card()?.querySelector('.send') as HTMLButtonElement).click()
209
+ expect(track).not.toHaveBeenCalled()
210
+ })
211
+ })
212
+
213
+ describe('resolvePreviewId', () => {
214
+ it('aceita só id no formato, guarda na aba e limpa com qualquer outro valor', () => {
215
+ const store = window.sessionStorage
216
+ expect(resolvePreviewId(`?holdy_preview=${ID}`, store)).toBe(ID)
217
+ expect(resolvePreviewId('', store)).toBe(ID)
218
+ expect(resolvePreviewId('?holdy_preview=<script>', store)).toBeNull()
219
+ expect(resolvePreviewId('', store)).toBeNull()
220
+ })
221
+ })
@@ -25,9 +25,47 @@ describe('changesForPage', () => {
25
25
  expect(out[0].change.id).toBe('c1')
26
26
  expect(out[0].arm).toBe(changeArm('c1', 'acme', 50))
27
27
  })
28
+
29
+ it('o checklist acompanha a conta em qualquer página', () => {
30
+ const list = [checklist(), change({ id: 'c2', path: '/billing' })]
31
+ const out = changesForPage(list, '/qualquer/pagina', 'acme')
32
+ expect(out.map((o) => o.change.id)).toEqual(['k1'])
33
+ })
34
+ })
35
+
36
+ const checklist = (over: Partial<ProductChange> = {}): ProductChange => ({
37
+ id: 'k1',
38
+ kind: 'checklist',
39
+ path: '/',
40
+ anchor: { kind: 'page' },
41
+ expected_text: null,
42
+ content: {
43
+ text: 'Primeiros passos',
44
+ items: [
45
+ { definition_name: 'Setup completo', label: 'Crie o seu espaço de trabalho', events: ['workspace_created'] },
46
+ { definition_name: 'Time convidado', label: 'Convide alguém do seu time', events: ['invite_sent'] },
47
+ ],
48
+ },
49
+ holdout_pct: 50,
50
+ ...over,
28
51
  })
29
52
 
30
53
  describe('parseChanges', () => {
54
+ it('pergunta só entra com o plano que perguntou, porque a resposta precisa saber a quem pertence', () => {
55
+ const question = change({ id: 'q1', kind: 'question', content: { text: 'O que te impediu de convidar o time?' } })
56
+ const body = { changes: [{ ...question, plan_id: 'plan-1' }, question] }
57
+ const parsed = parseChanges(body)
58
+ expect(parsed.map((c) => c.id)).toEqual(['q1'])
59
+ expect(parsed[0].plan_id).toBe('plan-1')
60
+ })
61
+
62
+ it('checklist só entra com itens no formato do contrato', () => {
63
+ const ok = checklist()
64
+ const semItens = checklist({ id: 'k2', content: { text: 'Vazio', items: [] } })
65
+ const itemTorto = checklist({ id: 'k3', content: { text: 'Torto', items: [{ definition_name: 'x', label: 'y' } as never] } })
66
+ expect(parseChanges({ changes: [ok, semItens, itemTorto] }).map((c) => c.id)).toEqual(['k1'])
67
+ })
68
+
31
69
  it('descarta o que não tem o formato do contrato', () => {
32
70
  const body = {
33
71
  changes: [