@tapthrough/expo 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +105 -2
- package/app.plugin.d.ts +37 -0
- package/app.plugin.js +79 -0
- package/dist/Annotator.d.ts +14 -0
- package/dist/Annotator.d.ts.map +1 -0
- package/dist/Annotator.js +160 -0
- package/dist/Annotator.js.map +1 -0
- package/dist/Bubble.d.ts +27 -0
- package/dist/Bubble.d.ts.map +1 -0
- package/dist/Bubble.js +234 -0
- package/dist/Bubble.js.map +1 -0
- package/dist/Feedback.d.ts +14 -0
- package/dist/Feedback.d.ts.map +1 -0
- package/dist/Feedback.js +977 -0
- package/dist/Feedback.js.map +1 -0
- package/dist/Overlay.d.ts +13 -0
- package/dist/Overlay.d.ts.map +1 -0
- package/dist/Overlay.js +44 -0
- package/dist/Overlay.js.map +1 -0
- package/dist/Popover.d.ts +98 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +531 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Tapthrough.d.ts +27 -0
- package/dist/Tapthrough.d.ts.map +1 -0
- package/dist/Tapthrough.js +111 -0
- package/dist/Tapthrough.js.map +1 -0
- package/dist/core/annotations.d.ts +63 -0
- package/dist/core/annotations.d.ts.map +1 -0
- package/dist/core/annotations.js +76 -0
- package/dist/core/annotations.js.map +1 -0
- package/dist/core/bubble.d.ts +21 -0
- package/dist/core/bubble.d.ts.map +1 -0
- package/dist/core/bubble.js +25 -0
- package/dist/core/bubble.js.map +1 -0
- package/dist/core/controller.d.ts +46 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +103 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/diagnostics.d.ts +47 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +101 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/feedback.d.ts +302 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +345 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/flags.d.ts +13 -0
- package/dist/core/flags.d.ts.map +1 -0
- package/dist/core/flags.js +25 -0
- package/dist/core/flags.js.map +1 -0
- package/dist/core/ideas.d.ts +166 -0
- package/dist/core/ideas.d.ts.map +1 -0
- package/dist/core/ideas.js +159 -0
- package/dist/core/ideas.js.map +1 -0
- package/dist/core/launch.d.ts +43 -0
- package/dist/core/launch.d.ts.map +1 -0
- package/dist/core/launch.js +81 -0
- package/dist/core/launch.js.map +1 -0
- package/dist/core/reset.d.ts +17 -0
- package/dist/core/reset.d.ts.map +1 -0
- package/dist/core/reset.js +19 -0
- package/dist/core/reset.js.map +1 -0
- package/dist/core/review.d.ts +49 -0
- package/dist/core/review.d.ts.map +1 -0
- package/dist/core/review.js +92 -0
- package/dist/core/review.js.map +1 -0
- package/dist/core/url.d.ts +9 -0
- package/dist/core/url.d.ts.map +1 -0
- package/dist/core/url.js +23 -0
- package/dist/core/url.js.map +1 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +13 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/native-intent.d.ts +18 -0
- package/dist/native-intent.d.ts.map +1 -0
- package/dist/native-intent.js +32 -0
- package/dist/native-intent.js.map +1 -0
- package/dist/runtime.d.ts +24 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +17 -0
- package/dist/runtime.js.map +1 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +48 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/supabase.d.ts +20 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +18 -0
- package/dist/supabase.js.map +1 -0
- package/dist/ui/native.d.ts +34 -0
- package/dist/ui/native.d.ts.map +1 -0
- package/dist/ui/native.js +76 -0
- package/dist/ui/native.js.map +1 -0
- package/dist/ui/parts.d.ts +62 -0
- package/dist/ui/parts.d.ts.map +1 -0
- package/dist/ui/parts.js +110 -0
- package/dist/ui/parts.js.map +1 -0
- package/dist/ui/theme.d.ts +90 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +61 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +82 -3
- package/src/Annotator.tsx +279 -0
- package/src/Bubble.tsx +331 -0
- package/src/Feedback.tsx +1739 -0
- package/src/Overlay.tsx +61 -0
- package/src/Popover.tsx +1393 -0
- package/src/Tapthrough.tsx +171 -0
- package/src/core/annotations.ts +116 -0
- package/src/core/bubble.ts +45 -0
- package/src/core/controller.ts +119 -0
- package/src/core/diagnostics.ts +128 -0
- package/src/core/feedback.ts +589 -0
- package/src/core/flags.ts +31 -0
- package/src/core/ideas.ts +337 -0
- package/src/core/launch.ts +119 -0
- package/src/core/reset.ts +28 -0
- package/src/core/review.ts +148 -0
- package/src/core/url.ts +21 -0
- package/src/hooks.ts +15 -0
- package/src/index.ts +8 -0
- package/src/native-intent.ts +36 -0
- package/src/runtime.ts +30 -0
- package/src/screenshot.ts +64 -0
- package/src/supabase.ts +27 -0
- package/src/ui/native.ts +116 -0
- package/src/ui/parts.tsx +249 -0
- 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
|
+
}
|