@tapthrough/expo 0.0.0-stage → 0.1.1

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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -2
  3. package/app.plugin.d.ts +37 -0
  4. package/app.plugin.js +79 -0
  5. package/dist/Annotator.d.ts +14 -0
  6. package/dist/Annotator.d.ts.map +1 -0
  7. package/dist/Annotator.js +160 -0
  8. package/dist/Annotator.js.map +1 -0
  9. package/dist/Bubble.d.ts +27 -0
  10. package/dist/Bubble.d.ts.map +1 -0
  11. package/dist/Bubble.js +234 -0
  12. package/dist/Bubble.js.map +1 -0
  13. package/dist/Feedback.d.ts +14 -0
  14. package/dist/Feedback.d.ts.map +1 -0
  15. package/dist/Feedback.js +977 -0
  16. package/dist/Feedback.js.map +1 -0
  17. package/dist/Overlay.d.ts +13 -0
  18. package/dist/Overlay.d.ts.map +1 -0
  19. package/dist/Overlay.js +44 -0
  20. package/dist/Overlay.js.map +1 -0
  21. package/dist/Popover.d.ts +98 -0
  22. package/dist/Popover.d.ts.map +1 -0
  23. package/dist/Popover.js +531 -0
  24. package/dist/Popover.js.map +1 -0
  25. package/dist/Tapthrough.d.ts +27 -0
  26. package/dist/Tapthrough.d.ts.map +1 -0
  27. package/dist/Tapthrough.js +111 -0
  28. package/dist/Tapthrough.js.map +1 -0
  29. package/dist/core/annotations.d.ts +63 -0
  30. package/dist/core/annotations.d.ts.map +1 -0
  31. package/dist/core/annotations.js +76 -0
  32. package/dist/core/annotations.js.map +1 -0
  33. package/dist/core/bubble.d.ts +21 -0
  34. package/dist/core/bubble.d.ts.map +1 -0
  35. package/dist/core/bubble.js +25 -0
  36. package/dist/core/bubble.js.map +1 -0
  37. package/dist/core/controller.d.ts +46 -0
  38. package/dist/core/controller.d.ts.map +1 -0
  39. package/dist/core/controller.js +103 -0
  40. package/dist/core/controller.js.map +1 -0
  41. package/dist/core/diagnostics.d.ts +47 -0
  42. package/dist/core/diagnostics.d.ts.map +1 -0
  43. package/dist/core/diagnostics.js +101 -0
  44. package/dist/core/diagnostics.js.map +1 -0
  45. package/dist/core/feedback.d.ts +302 -0
  46. package/dist/core/feedback.d.ts.map +1 -0
  47. package/dist/core/feedback.js +345 -0
  48. package/dist/core/feedback.js.map +1 -0
  49. package/dist/core/flags.d.ts +13 -0
  50. package/dist/core/flags.d.ts.map +1 -0
  51. package/dist/core/flags.js +25 -0
  52. package/dist/core/flags.js.map +1 -0
  53. package/dist/core/ideas.d.ts +166 -0
  54. package/dist/core/ideas.d.ts.map +1 -0
  55. package/dist/core/ideas.js +159 -0
  56. package/dist/core/ideas.js.map +1 -0
  57. package/dist/core/launch.d.ts +43 -0
  58. package/dist/core/launch.d.ts.map +1 -0
  59. package/dist/core/launch.js +81 -0
  60. package/dist/core/launch.js.map +1 -0
  61. package/dist/core/reset.d.ts +17 -0
  62. package/dist/core/reset.d.ts.map +1 -0
  63. package/dist/core/reset.js +19 -0
  64. package/dist/core/reset.js.map +1 -0
  65. package/dist/core/review.d.ts +49 -0
  66. package/dist/core/review.d.ts.map +1 -0
  67. package/dist/core/review.js +92 -0
  68. package/dist/core/review.js.map +1 -0
  69. package/dist/core/url.d.ts +9 -0
  70. package/dist/core/url.d.ts.map +1 -0
  71. package/dist/core/url.js +23 -0
  72. package/dist/core/url.js.map +1 -0
  73. package/dist/hooks.d.ts +6 -0
  74. package/dist/hooks.d.ts.map +1 -0
  75. package/dist/hooks.js +13 -0
  76. package/dist/hooks.js.map +1 -0
  77. package/dist/index.d.ts +9 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/native-intent.d.ts +18 -0
  82. package/dist/native-intent.d.ts.map +1 -0
  83. package/dist/native-intent.js +32 -0
  84. package/dist/native-intent.js.map +1 -0
  85. package/dist/runtime.d.ts +24 -0
  86. package/dist/runtime.d.ts.map +1 -0
  87. package/dist/runtime.js +17 -0
  88. package/dist/runtime.js.map +1 -0
  89. package/dist/screenshot.d.ts +7 -0
  90. package/dist/screenshot.d.ts.map +1 -0
  91. package/dist/screenshot.js +48 -0
  92. package/dist/screenshot.js.map +1 -0
  93. package/dist/supabase.d.ts +20 -0
  94. package/dist/supabase.d.ts.map +1 -0
  95. package/dist/supabase.js +18 -0
  96. package/dist/supabase.js.map +1 -0
  97. package/dist/ui/native.d.ts +34 -0
  98. package/dist/ui/native.d.ts.map +1 -0
  99. package/dist/ui/native.js +76 -0
  100. package/dist/ui/native.js.map +1 -0
  101. package/dist/ui/parts.d.ts +62 -0
  102. package/dist/ui/parts.d.ts.map +1 -0
  103. package/dist/ui/parts.js +110 -0
  104. package/dist/ui/parts.js.map +1 -0
  105. package/dist/ui/theme.d.ts +90 -0
  106. package/dist/ui/theme.d.ts.map +1 -0
  107. package/dist/ui/theme.js +61 -0
  108. package/dist/ui/theme.js.map +1 -0
  109. package/package.json +82 -3
  110. package/src/Annotator.tsx +279 -0
  111. package/src/Bubble.tsx +331 -0
  112. package/src/Feedback.tsx +1739 -0
  113. package/src/Overlay.tsx +61 -0
  114. package/src/Popover.tsx +1393 -0
  115. package/src/Tapthrough.tsx +171 -0
  116. package/src/core/annotations.ts +116 -0
  117. package/src/core/bubble.ts +45 -0
  118. package/src/core/controller.ts +119 -0
  119. package/src/core/diagnostics.ts +128 -0
  120. package/src/core/feedback.ts +589 -0
  121. package/src/core/flags.ts +31 -0
  122. package/src/core/ideas.ts +337 -0
  123. package/src/core/launch.ts +119 -0
  124. package/src/core/reset.ts +28 -0
  125. package/src/core/review.ts +148 -0
  126. package/src/core/url.ts +21 -0
  127. package/src/hooks.ts +15 -0
  128. package/src/index.ts +8 -0
  129. package/src/native-intent.ts +36 -0
  130. package/src/runtime.ts +30 -0
  131. package/src/screenshot.ts +64 -0
  132. package/src/supabase.ts +27 -0
  133. package/src/ui/native.ts +116 -0
  134. package/src/ui/parts.tsx +249 -0
  135. package/src/ui/theme.ts +75 -0
@@ -0,0 +1,589 @@
1
+ import type { Platform } from '@tapthrough/protocol'
2
+ import type { Annotation } from './annotations.js'
3
+ import type { Diagnostics } from './diagnostics.js'
4
+
5
+ /** Which PR build is running, read from the loaded EAS update's manifest. */
6
+ export interface PreviewContext {
7
+ updateGroupId: string
8
+ /** The EAS project the update belongs to; the server checks it against the repo. */
9
+ projectId: string
10
+ /** The EAS Update branch, e.g. `pr-142`. */
11
+ branch: string | undefined
12
+ }
13
+
14
+ /**
15
+ * The update group of the running EAS update, from `Constants.manifest2`.
16
+ * A development build has it however the update was opened (QR code, Orbit
17
+ * or a Tapthrough link); an embedded or local build has none.
18
+ */
19
+ export function previewContextFromManifest(manifest: unknown): PreviewContext | null {
20
+ const m = manifest as {
21
+ metadata?: { updateGroup?: unknown; branchName?: unknown }
22
+ extra?: { eas?: { projectId?: unknown } }
23
+ } | null
24
+ const metadata = m?.metadata
25
+ const projectId = m?.extra?.eas?.projectId
26
+ if (typeof metadata?.updateGroup !== 'string' || metadata.updateGroup === '') return null
27
+ if (typeof projectId !== 'string') return null
28
+ return {
29
+ updateGroupId: metadata.updateGroup,
30
+ projectId,
31
+ branch: typeof metadata.branchName === 'string' ? metadata.branchName : undefined,
32
+ }
33
+ }
34
+
35
+ /** A link token's payload, read without verification (the seed hook and server verify). */
36
+ export function decodeTokenClaims(token: string): Record<string, unknown> | undefined {
37
+ const payload = token.split('.')[1]
38
+ if (!payload) return undefined
39
+ try {
40
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/')
41
+ const json: unknown = JSON.parse(
42
+ globalThis.atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')),
43
+ )
44
+ return json && typeof json === 'object' ? (json as Record<string, unknown>) : undefined
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The scenario ID (`scn`) from a link token, for attributing reports. The
52
+ * server checks the ID belongs to the same PR.
53
+ */
54
+ export function scenarioIdFromToken(token: string): string | undefined {
55
+ const scn = decodeTokenClaims(token)?.scn
56
+ return typeof scn === 'string' ? scn : undefined
57
+ }
58
+
59
+ export interface FeedbackInput {
60
+ preview: PreviewContext
61
+ platform: Platform
62
+ body: string
63
+ reporterName?: string
64
+ route?: string
65
+ scenarioId?: string
66
+ device?: { os?: string; osVersion?: string; model?: string }
67
+ screenshot?: { contentType: 'image/jpeg' | 'image/png'; base64: string }
68
+ diagnostics?: Diagnostics
69
+ /** Boxes drawn on the screenshot; only sent with a screenshot. */
70
+ annotations?: Annotation[]
71
+ /** The reporter's own call; left out, Tapthrough guesses. */
72
+ cause?: 'pr' | 'unrelated'
73
+ }
74
+
75
+ /** Whether a report is caused by the PR: Tapthrough's guess unless a person said. */
76
+ export type Cause = 'pr' | 'unrelated' | 'unknown'
77
+
78
+ export interface Triage {
79
+ cause: Cause
80
+ causeSource: 'guess' | 'reporter' | 'developer'
81
+ /** Plain-text reasons behind the guess. */
82
+ reasons: string[]
83
+ /** GitHub's prefilled new-issue form, for bugs the PR didn't cause. */
84
+ issueUrl?: string
85
+ /** `auto`: an issue filed from here gets `tapthrough:fix` and Claude starts. `label`: a developer adds it. */
86
+ issueFix?: 'auto' | 'label'
87
+ }
88
+
89
+ export type FeedbackResult =
90
+ | ({ ok: true; commentUrl: string; findingId?: string } & Partial<Triage>)
91
+ | { ok: false; message: string }
92
+
93
+ /** Markdown reasons from the server, as plain text for the app. */
94
+ export function plainReason(reason: string): string {
95
+ return reason.replace(/`([^`]*)`/g, '$1').replace(/\*\*/g, '')
96
+ }
97
+
98
+ /** Posts a report to Tapthrough, which comments it on the PR. Never throws. */
99
+ export async function sendFeedback(
100
+ input: FeedbackInput,
101
+ options: { apiUrl: string; fetch?: typeof fetch },
102
+ ): Promise<FeedbackResult> {
103
+ const doFetch = options.fetch ?? fetch
104
+ let response: Response
105
+ try {
106
+ response = await doFetch(`${options.apiUrl.replace(/\/$/, '')}/api/v1/feedback`, {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify({
110
+ apiVersion: 1,
111
+ updateGroupId: input.preview.updateGroupId,
112
+ projectId: input.preview.projectId,
113
+ platform: input.platform,
114
+ body: input.body,
115
+ ...(input.reporterName ? { reporterName: input.reporterName } : {}),
116
+ ...(input.route ? { route: input.route } : {}),
117
+ ...(input.scenarioId ? { scenarioId: input.scenarioId } : {}),
118
+ ...(input.device ? { device: input.device } : {}),
119
+ ...(input.screenshot ? { screenshot: input.screenshot } : {}),
120
+ ...(input.diagnostics ? { diagnostics: input.diagnostics } : {}),
121
+ ...(input.screenshot && input.annotations?.length
122
+ ? { annotations: input.annotations }
123
+ : {}),
124
+ ...(input.cause ? { cause: input.cause } : {}),
125
+ }),
126
+ })
127
+ } catch {
128
+ return { ok: false, message: "Couldn't reach Tapthrough. Check your connection and try again." }
129
+ }
130
+ const body = (await response.json().catch(() => undefined)) as
131
+ | ({ commentUrl?: string; findingId?: string; error?: { message?: string } } & Partial<Triage>)
132
+ | undefined
133
+ if (response.ok && body?.commentUrl) {
134
+ return {
135
+ ok: true,
136
+ commentUrl: body.commentUrl,
137
+ ...(body.findingId ? { findingId: body.findingId } : {}),
138
+ // Older servers don't triage; the app then just asks.
139
+ cause: body.cause ?? 'unknown',
140
+ causeSource: body.causeSource ?? 'guess',
141
+ reasons: (body.reasons ?? []).map(plainReason),
142
+ ...(body.issueUrl ? { issueUrl: body.issueUrl } : {}),
143
+ ...(body.issueFix === 'auto' || body.issueFix === 'label' ? { issueFix: body.issueFix } : {}),
144
+ }
145
+ }
146
+ return { ok: false, message: body?.error?.message ?? `Tapthrough returned ${response.status}.` }
147
+ }
148
+
149
+ export type StepResultValue = 'pass' | 'fail' | 'skip'
150
+
151
+ export interface ChecklistStep {
152
+ index: number
153
+ action: string
154
+ expect: string
155
+ result: {
156
+ result: StepResultValue
157
+ reporter: string | null
158
+ platform: Platform
159
+ /** Checked on this earlier commit; the push since didn't touch the screen. */
160
+ carriedFrom?: string
161
+ } | null
162
+ }
163
+
164
+ /** Checks from an earlier commit that a push cleared, and why. */
165
+ export interface ChecklistReset {
166
+ fromSha: string
167
+ cleared: number
168
+ reason: 'code' | 'config' | 'unknown'
169
+ }
170
+
171
+ /** Why the checklist started over, for a reviewer. */
172
+ export function resetMessage(reset: ChecklistReset): string {
173
+ const checks = reset.cleared === 1 ? '1 check' : `${reset.cleared} checks`
174
+ const why =
175
+ reset.reason === 'code'
176
+ ? 'this push changed the code they cover'
177
+ : reset.reason === 'config'
178
+ ? 'this push changed dependencies or app config, which can affect any screen'
179
+ : 'Tapthrough couldn’t tell what this push changed'
180
+ return `${checks} from ${reset.fromSha.slice(0, 7)} ${reset.cleared === 1 ? 'was' : 'were'} cleared because ${why}. Checks on screens it didn’t touch were kept.`
181
+ }
182
+
183
+ /** A report from an earlier commit, to re-check on this build. */
184
+ export interface RecheckItem {
185
+ id: string
186
+ excerpt: string
187
+ reporter: string | null
188
+ sha: string
189
+ platform: Platform
190
+ stillBrokenHere: boolean
191
+ }
192
+
193
+ /** A later push to the PR than the build that's running. */
194
+ export interface NewerBuild {
195
+ sha: string
196
+ updateGroupId: string
197
+ /** Opens it in the development build; null when the server can't build the link. */
198
+ openUrl: string | null
199
+ /** True when the push is a fix Claude made for a report. */
200
+ hasFix: boolean
201
+ }
202
+
203
+ /**
204
+ * One part of getting the app into a scenario's starting state, said two ways:
205
+ * what Tapthrough does when it sets the app up, and what a person does by hand.
206
+ */
207
+ export interface SetupLine {
208
+ kind: 'account' | 'data' | 'flag' | 'screen'
209
+ /** e.g. "Signs you in as a test user with the viewer role" */
210
+ auto: string
211
+ /** e.g. "Sign in as a user with the viewer role" */
212
+ manual: string
213
+ }
214
+
215
+ export interface ChecklistScenario {
216
+ key: string
217
+ title: string
218
+ routePath: string
219
+ /** Why the PR runs it, e.g. "This PR changes src/app/index.tsx". Older servers omit it. */
220
+ why?: string | null
221
+ /** How the starting state is made. Older servers omit it. */
222
+ setup?: SetupLine[]
223
+ steps: ChecklistStep[]
224
+ }
225
+
226
+ /** A scenario in the run that isn't part of the PR's plan, for jumping into its state. */
227
+ export interface LibraryEntry {
228
+ key: string
229
+ title: string
230
+ routePath: string
231
+ /** True for scenarios already in `scenarios`. */
232
+ picked: boolean
233
+ why?: string | null
234
+ setup?: SetupLine[]
235
+ }
236
+
237
+ export interface Checklist {
238
+ pr: number
239
+ sha: string
240
+ open: boolean
241
+ scenarios: ChecklistScenario[]
242
+ recheck?: RecheckItem[]
243
+ newerBuild?: NewerBuild | null
244
+ library?: LibraryEntry[]
245
+ reset?: ChecklistReset | null
246
+ }
247
+
248
+ /** Whether the Review sheet has anything to show. */
249
+ export function hasReviewItems(checklist: Checklist | null): checklist is Checklist {
250
+ return Boolean(
251
+ checklist &&
252
+ (checklist.scenarios.some((s) => s.steps.length > 0) ||
253
+ (checklist.recheck?.length ?? 0) > 0 ||
254
+ checklist.library?.some((entry) => !entry.picked) ||
255
+ checklist.newerBuild),
256
+ )
257
+ }
258
+
259
+ export interface Progress {
260
+ total: number
261
+ passed: number
262
+ failed: number
263
+ }
264
+
265
+ /** Step results across the plan, keyed `scenario:index` like the Review sheet. */
266
+ export function checklistProgress(
267
+ checklist: Checklist,
268
+ results: Record<string, StepResultValue | undefined> = {},
269
+ ): Progress {
270
+ const progress = { total: 0, passed: 0, failed: 0 }
271
+ for (const scenario of checklist.scenarios) {
272
+ for (const step of scenario.steps) {
273
+ progress.total += 1
274
+ const result = results[`${scenario.key}:${step.index}`] ?? step.result?.result
275
+ if (result === 'pass') progress.passed += 1
276
+ if (result === 'fail') progress.failed += 1
277
+ }
278
+ }
279
+ return progress
280
+ }
281
+
282
+ /** One line for the sheet's header, e.g. `1 of 3 checked` or `3 of 3 passed`. */
283
+ export function progressLabel({ total, passed, failed }: Progress): string {
284
+ if (total === 0) return 'No steps to check'
285
+ if (passed === total) return `${total} of ${total} passed`
286
+ const label = `${passed + failed} of ${total} checked`
287
+ return failed ? `${label} · ${failed} failed` : label
288
+ }
289
+
290
+ export type Badge =
291
+ | { kind: 'new-build' }
292
+ | { kind: 'failed' }
293
+ | { kind: 'remaining'; count: number }
294
+ | { kind: 'done' }
295
+ /** Nothing to check here, but an idea waits on someone: questions, or a build to test. */
296
+ | { kind: 'idea' }
297
+
298
+ /** What the floating Review button shows: a new build wins, then failures, then steps left. */
299
+ export function reviewBadge(checklist: Checklist | null): Badge | null {
300
+ if (!checklist) return null
301
+ if (checklist.newerBuild) return { kind: 'new-build' }
302
+ const progress = checklistProgress(checklist)
303
+ if (progress.total === 0) return null
304
+ if (progress.failed) return { kind: 'failed' }
305
+ const count = progress.total - progress.passed
306
+ return count === 0 ? { kind: 'done' } : { kind: 'remaining', count }
307
+ }
308
+
309
+ /** Marks an earlier report fixed or still broken on this build. Never throws. */
310
+ export async function sendRecheck(
311
+ input: {
312
+ preview: PreviewContext
313
+ platform: Platform
314
+ findingId: string
315
+ result: 'fixed' | 'still_broken'
316
+ reporterName?: string
317
+ },
318
+ options: { apiUrl: string; fetch?: typeof fetch },
319
+ ): Promise<{ ok: true } | { ok: false; message: string }> {
320
+ const doFetch = options.fetch ?? fetch
321
+ try {
322
+ const response = await doFetch(
323
+ `${options.apiUrl.replace(/\/$/, '')}/api/v1/findings/${encodeURIComponent(input.findingId)}/check`,
324
+ {
325
+ method: 'POST',
326
+ headers: { 'Content-Type': 'application/json' },
327
+ body: JSON.stringify({
328
+ apiVersion: 1,
329
+ updateGroupId: input.preview.updateGroupId,
330
+ projectId: input.preview.projectId,
331
+ platform: input.platform,
332
+ result: input.result,
333
+ ...(input.reporterName ? { reporterName: input.reporterName } : {}),
334
+ }),
335
+ },
336
+ )
337
+ if (response.ok) return { ok: true }
338
+ const body = (await response.json().catch(() => undefined)) as
339
+ | { error?: { message?: string } }
340
+ | undefined
341
+ return { ok: false, message: body?.error?.message ?? `Tapthrough returned ${response.status}.` }
342
+ } catch {
343
+ return { ok: false, message: "Couldn't reach Tapthrough. Check your connection and try again." }
344
+ }
345
+ }
346
+
347
+ /** The reporter saying whether the PR caused a report. Never throws. */
348
+ export async function sendCause(
349
+ input: {
350
+ preview: PreviewContext
351
+ platform: Platform
352
+ findingId: string
353
+ cause: 'pr' | 'unrelated'
354
+ },
355
+ options: { apiUrl: string; fetch?: typeof fetch },
356
+ ): Promise<{ ok: true } | { ok: false; message: string }> {
357
+ const doFetch = options.fetch ?? fetch
358
+ try {
359
+ const response = await doFetch(
360
+ `${options.apiUrl.replace(/\/$/, '')}/api/v1/findings/${encodeURIComponent(input.findingId)}/cause`,
361
+ {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({
365
+ apiVersion: 1,
366
+ updateGroupId: input.preview.updateGroupId,
367
+ projectId: input.preview.projectId,
368
+ platform: input.platform,
369
+ cause: input.cause,
370
+ }),
371
+ },
372
+ )
373
+ if (response.ok) return { ok: true }
374
+ const body = (await response.json().catch(() => undefined)) as
375
+ | { error?: { message?: string } }
376
+ | undefined
377
+ return { ok: false, message: body?.error?.message ?? `Tapthrough returned ${response.status}.` }
378
+ } catch {
379
+ return { ok: false, message: "Couldn't reach Tapthrough. Check your connection and try again." }
380
+ }
381
+ }
382
+
383
+ export type FixRequestResult =
384
+ | { ok: true; status: 'requested' | 'not_set_up' }
385
+ | { ok: false; message: string }
386
+
387
+ /** Asks Claude to fix a report on the PR's branch, in the repo's own Actions. Never throws. */
388
+ export async function requestFix(
389
+ input: {
390
+ preview: PreviewContext
391
+ platform: Platform
392
+ findingId: string
393
+ reporterName?: string
394
+ },
395
+ options: { apiUrl: string; fetch?: typeof fetch },
396
+ ): Promise<FixRequestResult> {
397
+ const doFetch = options.fetch ?? fetch
398
+ try {
399
+ const response = await doFetch(
400
+ `${options.apiUrl.replace(/\/$/, '')}/api/v1/findings/${encodeURIComponent(input.findingId)}/fix`,
401
+ {
402
+ method: 'POST',
403
+ headers: { 'Content-Type': 'application/json' },
404
+ body: JSON.stringify({
405
+ apiVersion: 1,
406
+ updateGroupId: input.preview.updateGroupId,
407
+ projectId: input.preview.projectId,
408
+ platform: input.platform,
409
+ ...(input.reporterName ? { reporterName: input.reporterName } : {}),
410
+ }),
411
+ },
412
+ )
413
+ const body = (await response.json().catch(() => undefined)) as
414
+ | { status?: 'requested' | 'not_set_up'; error?: { message?: string } }
415
+ | undefined
416
+ if (response.ok && body?.status) return { ok: true, status: body.status }
417
+ return { ok: false, message: body?.error?.message ?? `Tapthrough returned ${response.status}.` }
418
+ } catch {
419
+ return { ok: false, message: "Couldn't reach Tapthrough. Check your connection and try again." }
420
+ }
421
+ }
422
+
423
+ /** What happened to Claude after filing; `off` means a developer adds the label, null that it was filed before. */
424
+ export type IssueFix = 'started' | 'not_set_up' | 'refused' | 'off' | null
425
+
426
+ export type IssueFileResult =
427
+ | { ok: true; issueNumber: number; issueUrl: string; fix: IssueFix }
428
+ /** `openInBrowser`: Tapthrough can't file it here, so offer GitHub's prefilled form instead. */
429
+ | { ok: false; message: string; openInBrowser: boolean }
430
+
431
+ /**
432
+ * Files a report as a GitHub issue through Tapthrough, so the reporter doesn't
433
+ * need to sign in to GitHub on the phone. Never throws.
434
+ */
435
+ export async function fileIssue(
436
+ input: { preview: PreviewContext; platform: Platform; findingId: string },
437
+ options: { apiUrl: string; fetch?: typeof fetch },
438
+ ): Promise<IssueFileResult> {
439
+ const doFetch = options.fetch ?? fetch
440
+ try {
441
+ const response = await doFetch(
442
+ `${options.apiUrl.replace(/\/$/, '')}/api/v1/findings/${encodeURIComponent(input.findingId)}/issue`,
443
+ {
444
+ method: 'POST',
445
+ headers: { 'Content-Type': 'application/json' },
446
+ body: JSON.stringify({
447
+ apiVersion: 1,
448
+ updateGroupId: input.preview.updateGroupId,
449
+ projectId: input.preview.projectId,
450
+ platform: input.platform,
451
+ }),
452
+ },
453
+ )
454
+ const body = (await response.json().catch(() => undefined)) as
455
+ | {
456
+ issueNumber?: number
457
+ issueUrl?: string
458
+ fix?: IssueFix
459
+ error?: { code?: string; message?: string }
460
+ }
461
+ | undefined
462
+ if (response.ok && body?.issueNumber && body.issueUrl) {
463
+ const fix = ['started', 'not_set_up', 'refused', 'off'].includes(String(body.fix))
464
+ ? (body.fix as IssueFix)
465
+ : null
466
+ return { ok: true, issueNumber: body.issueNumber, issueUrl: body.issueUrl, fix }
467
+ }
468
+ // A server from before this endpoint answers 404 with no error code.
469
+ const openInBrowser =
470
+ body?.error?.code === 'github_denied' || (response.status === 404 && !body?.error?.code)
471
+ return {
472
+ ok: false,
473
+ message: body?.error?.message ?? `Tapthrough returned ${response.status}.`,
474
+ openInBrowser,
475
+ }
476
+ } catch {
477
+ return {
478
+ ok: false,
479
+ message: "Couldn't reach Tapthrough. Check your connection and try again.",
480
+ openInBrowser: false,
481
+ }
482
+ }
483
+ }
484
+
485
+ export type LaunchRequestResult = { ok: true; launchUrl: string } | { ok: false; message: string }
486
+
487
+ /**
488
+ * Asks Tapthrough for a launch link into one of the PR's scenarios, so the
489
+ * app can set itself up for it without leaving. Never throws.
490
+ */
491
+ export async function requestLaunch(
492
+ input: { preview: PreviewContext; platform: Platform; scenarioKey: string },
493
+ options: { apiUrl: string; fetch?: typeof fetch },
494
+ ): Promise<LaunchRequestResult> {
495
+ const doFetch = options.fetch ?? fetch
496
+ try {
497
+ const response = await doFetch(`${options.apiUrl.replace(/\/$/, '')}/api/v1/checklist/launch`, {
498
+ method: 'POST',
499
+ headers: { 'Content-Type': 'application/json' },
500
+ body: JSON.stringify({
501
+ apiVersion: 1,
502
+ updateGroupId: input.preview.updateGroupId,
503
+ projectId: input.preview.projectId,
504
+ platform: input.platform,
505
+ scenario: input.scenarioKey,
506
+ }),
507
+ })
508
+ const body = (await response.json().catch(() => undefined)) as
509
+ | { launchUrl?: string; error?: { message?: string } }
510
+ | undefined
511
+ if (response.ok && typeof body?.launchUrl === 'string') {
512
+ return { ok: true, launchUrl: body.launchUrl }
513
+ }
514
+ if (response.status === 404 && !body?.error) {
515
+ return { ok: false, message: 'This version of Tapthrough can’t set the app up yet.' }
516
+ }
517
+ return { ok: false, message: body?.error?.message ?? `Tapthrough returned ${response.status}.` }
518
+ } catch {
519
+ return { ok: false, message: "Couldn't reach Tapthrough. Check your connection and try again." }
520
+ }
521
+ }
522
+
523
+ /** The PR's test plan and current results for the running build, or null if there isn't one. */
524
+ export async function fetchChecklist(
525
+ preview: PreviewContext,
526
+ platform: Platform,
527
+ options: { apiUrl: string; fetch?: typeof fetch },
528
+ ): Promise<Checklist | null> {
529
+ const doFetch = options.fetch ?? fetch
530
+ const query = new URLSearchParams({
531
+ updateGroupId: preview.updateGroupId,
532
+ projectId: preview.projectId,
533
+ platform,
534
+ })
535
+ try {
536
+ const response = await doFetch(`${options.apiUrl.replace(/\/$/, '')}/api/v1/checklist?${query}`)
537
+ if (!response.ok) return null
538
+ return (await response.json()) as Checklist
539
+ } catch {
540
+ return null
541
+ }
542
+ }
543
+
544
+ /** Records a pass, fail or skip for one step. Never throws. */
545
+ export async function sendStepResult(
546
+ input: {
547
+ preview: PreviewContext
548
+ platform: Platform
549
+ scenarioKey: string
550
+ stepIndex: number
551
+ result: StepResultValue
552
+ reporterName?: string
553
+ findingId?: string
554
+ },
555
+ options: { apiUrl: string; fetch?: typeof fetch },
556
+ ): Promise<{ ok: true } | { ok: false; message: string }> {
557
+ const doFetch = options.fetch ?? fetch
558
+ try {
559
+ const response = await doFetch(`${options.apiUrl.replace(/\/$/, '')}/api/v1/step-results`, {
560
+ method: 'POST',
561
+ headers: { 'Content-Type': 'application/json' },
562
+ body: JSON.stringify({
563
+ apiVersion: 1,
564
+ updateGroupId: input.preview.updateGroupId,
565
+ projectId: input.preview.projectId,
566
+ platform: input.platform,
567
+ scenarioKey: input.scenarioKey,
568
+ stepIndex: input.stepIndex,
569
+ result: input.result,
570
+ ...(input.reporterName ? { reporterName: input.reporterName } : {}),
571
+ ...(input.findingId ? { findingId: input.findingId } : {}),
572
+ }),
573
+ })
574
+ if (response.ok) return { ok: true }
575
+ const body = (await response.json().catch(() => undefined)) as
576
+ | { error?: { message?: string } }
577
+ | undefined
578
+ return { ok: false, message: body?.error?.message ?? `Tapthrough returned ${response.status}.` }
579
+ } catch {
580
+ return { ok: false, message: "Couldn't reach Tapthrough. Check your connection and try again." }
581
+ }
582
+ }
583
+
584
+ /** The report body for a failed step: the step and its expectation, then the reviewer's note. */
585
+ export function failedStepBody(step: { action: string; expect: string }, note: string): string {
586
+ const parts = [`Step failed: ${step.action}`, `Expected: ${step.expect}`]
587
+ if (note.trim()) parts.push('', note.trim())
588
+ return parts.join('\n')
589
+ }
@@ -0,0 +1,31 @@
1
+ import type { Flags } from '@tapthrough/protocol'
2
+
3
+ type Listener = () => void
4
+
5
+ let overrides: Readonly<Flags> = {}
6
+ const listeners = new Set<Listener>()
7
+
8
+ /**
9
+ * Feature flags a scenario forces on or off. Your flag lookup should check
10
+ * these first: `getFlagOverride('newEditor') ?? yourFlags.newEditor`.
11
+ */
12
+ export function getFlagOverride(name: string): boolean | string | undefined {
13
+ return Object.hasOwn(overrides, name) ? overrides[name] : undefined
14
+ }
15
+
16
+ export function getFlagOverrides(): Readonly<Flags> {
17
+ return overrides
18
+ }
19
+
20
+ /** Replaces all overrides. Called by the launch sequence. */
21
+ export function setFlagOverrides(next: Flags): void {
22
+ overrides = Object.freeze({ ...next })
23
+ for (const listener of listeners) listener()
24
+ }
25
+
26
+ export function subscribeToFlagOverrides(listener: Listener): () => void {
27
+ listeners.add(listener)
28
+ return () => {
29
+ listeners.delete(listener)
30
+ }
31
+ }