@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.
- package/README.md +8 -1
- package/dist/changes.d.ts +16 -2
- package/dist/changes.d.ts.map +1 -1
- package/dist/core.d.ts +1 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/friction.d.ts +2 -1
- package/dist/friction.d.ts.map +1 -1
- package/dist/holdy.js +25 -4
- package/dist/holdy.js.map +4 -4
- package/dist/index.cjs +420 -36
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +420 -36
- package/dist/index.mjs.map +4 -4
- package/dist/server.cjs +3 -0
- package/dist/server.cjs.map +2 -2
- package/dist/server.mjs +3 -0
- package/dist/server.mjs.map +2 -2
- package/dist/skeleton.d.ts +3 -1
- package/dist/skeleton.d.ts.map +1 -1
- package/dist/theme.d.ts +43 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/tip-css.d.ts +9 -0
- package/dist/tip-css.d.ts.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/web-context.d.ts +1 -0
- package/dist/web-context.d.ts.map +1 -1
- package/dist/web.d.ts +2 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +420 -36
- package/dist/web.mjs.map +4 -4
- package/package.json +1 -1
- package/src/changes.dom.test.ts +221 -0
- package/src/changes.test.ts +38 -0
- package/src/changes.ts +299 -26
- package/src/core.test.ts +15 -0
- package/src/core.ts +3 -0
- package/src/friction.dom.test.ts +54 -0
- package/src/friction.test.ts +43 -0
- package/src/friction.ts +44 -7
- package/src/skeleton.ts +12 -3
- package/src/theme.test.ts +118 -0
- package/src/theme.ts +120 -0
- package/src/tip-css.ts +32 -0
- package/src/types.ts +6 -0
- package/src/web-context.test.ts +18 -0
- package/src/web-context.ts +8 -0
- 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
|
+
})
|
package/src/changes.test.ts
CHANGED
|
@@ -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: [
|