@mobilewright/inspector 0.0.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.
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/device-manager.d.ts +71 -0
- package/dist/lib/device-manager.d.ts.map +1 -0
- package/dist/lib/device-manager.js +142 -0
- package/dist/lib/device-manager.js.map +1 -0
- package/dist/lib/locator-derivation.d.ts +30 -0
- package/dist/lib/locator-derivation.d.ts.map +1 -0
- package/dist/lib/locator-derivation.js +69 -0
- package/dist/lib/locator-derivation.js.map +1 -0
- package/dist/lib/logger.d.ts +10 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +14 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/routes/devices.d.ts +7 -0
- package/dist/routes/devices.d.ts.map +1 -0
- package/dist/routes/devices.js +43 -0
- package/dist/routes/devices.js.map +1 -0
- package/dist/routes/inspect.d.ts +7 -0
- package/dist/routes/inspect.d.ts.map +1 -0
- package/dist/routes/inspect.js +79 -0
- package/dist/routes/inspect.js.map +1 -0
- package/package.json +42 -0
- package/public/css/app.css +453 -0
- package/public/index.html +67 -0
- package/public/js/app.js +572 -0
package/public/js/app.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// Mobilewright Inspector frontend. No framework, no build step.
|
|
2
|
+
|
|
3
|
+
// ---- Pure locator utilities ----
|
|
4
|
+
|
|
5
|
+
const DEFAULT_HIDDEN_TESTIDS = new Set(['android:id/content'])
|
|
6
|
+
|
|
7
|
+
function locatorKey(locator) {
|
|
8
|
+
if (locator.kind === 'role') return `role:${locator.value}:${locator.name ?? ''}`
|
|
9
|
+
return `${locator.kind}:${locator.value}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function escQ(s) {
|
|
13
|
+
return s
|
|
14
|
+
.replace(/\\/g, '\\\\')
|
|
15
|
+
.replace(/'/g, "\\'")
|
|
16
|
+
.replace(/\r/g, '\\r')
|
|
17
|
+
.replace(/\n/g, '\\n')
|
|
18
|
+
.replace(/\u2028/g, '\\u2028')
|
|
19
|
+
.replace(/\u2029/g, '\\u2029')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function locatorLabel(locator) {
|
|
23
|
+
if (locator.kind === 'testId') return `getByTestId('${escQ(locator.value)}')`
|
|
24
|
+
if (locator.kind === 'role') {
|
|
25
|
+
return locator.name
|
|
26
|
+
? `getByRole('${escQ(locator.value)}', { name: '${escQ(locator.name)}' })`
|
|
27
|
+
: `getByRole('${escQ(locator.value)}')`
|
|
28
|
+
}
|
|
29
|
+
if (locator.kind === 'label') return `getByLabel('${escQ(locator.value)}')`
|
|
30
|
+
if (locator.kind === 'text') return `getByText('${escQ(locator.value)}')`
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildDuplicateSet(elements) {
|
|
35
|
+
const counts = new Map()
|
|
36
|
+
for (const el of elements) {
|
|
37
|
+
if (!el.locator) continue
|
|
38
|
+
const key = locatorKey(el.locator)
|
|
39
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
40
|
+
}
|
|
41
|
+
const dupes = new Set()
|
|
42
|
+
for (const [k, n] of counts) if (n > 1) dupes.add(k)
|
|
43
|
+
return dupes
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- ScreenshotPane ----
|
|
47
|
+
// Owns the screenshot image, SVG highlight overlay, and placeholder state.
|
|
48
|
+
|
|
49
|
+
class ScreenshotPane {
|
|
50
|
+
#img
|
|
51
|
+
#overlay
|
|
52
|
+
#placeholder
|
|
53
|
+
#placeholderTitle
|
|
54
|
+
#placeholderSub
|
|
55
|
+
#logicalWidth = 0
|
|
56
|
+
#logicalHeight = 0
|
|
57
|
+
#elements = []
|
|
58
|
+
#hiddenIndices = new Set()
|
|
59
|
+
#selectedIndex = null
|
|
60
|
+
#onClickCb = null
|
|
61
|
+
#screenshotPane // cached pane element for #constrainSize
|
|
62
|
+
// O(1) lookup from element index to its SVG rect; rebuilt on each renderHighlights call.
|
|
63
|
+
#rectByIndex = new Map()
|
|
64
|
+
|
|
65
|
+
constructor() {
|
|
66
|
+
this.#img = document.getElementById('screenshot-img')
|
|
67
|
+
this.#overlay = document.getElementById('highlight-overlay')
|
|
68
|
+
this.#placeholder = document.getElementById('no-device-msg')
|
|
69
|
+
this.#placeholderTitle = document.getElementById('placeholder-title')
|
|
70
|
+
this.#placeholderSub = document.getElementById('placeholder-sub')
|
|
71
|
+
this.#screenshotPane = document.getElementById('screenshot-pane')
|
|
72
|
+
window.addEventListener('resize', () => this.#constrainSize())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onElementClick(cb) { this.#onClickCb = cb }
|
|
76
|
+
|
|
77
|
+
get isScreenshotHidden() { return this.#img.hidden }
|
|
78
|
+
|
|
79
|
+
showPlaceholder(title, sub = '', loading = false) {
|
|
80
|
+
this.#placeholder.hidden = false
|
|
81
|
+
this.#img.hidden = true
|
|
82
|
+
this.#overlay.setAttribute('hidden', '')
|
|
83
|
+
this.#placeholderTitle.textContent = title
|
|
84
|
+
this.#placeholderSub.textContent = sub
|
|
85
|
+
this.#placeholder.classList.toggle('loading', loading)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
showScreenshot(dataUrl, logicalWidth = 0, logicalHeight = 0) {
|
|
89
|
+
this.#logicalWidth = logicalWidth
|
|
90
|
+
this.#logicalHeight = logicalHeight
|
|
91
|
+
this.#placeholder.hidden = true
|
|
92
|
+
this.#placeholder.classList.remove('loading')
|
|
93
|
+
this.#img.hidden = false
|
|
94
|
+
this.#overlay.removeAttribute('hidden')
|
|
95
|
+
this.#img.src = dataUrl
|
|
96
|
+
this.#img.onload = () => this.#constrainSize()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
renderHighlights(elements, hiddenIndices, selectedIndex) {
|
|
100
|
+
this.#elements = elements
|
|
101
|
+
this.#hiddenIndices = hiddenIndices
|
|
102
|
+
this.#selectedIndex = selectedIndex
|
|
103
|
+
this.#buildRects()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setSelectedIndex(index) {
|
|
107
|
+
// O(1): deselect old rect, select new one directly via index map.
|
|
108
|
+
this.#rectByIndex.get(this.#selectedIndex)?.classList.remove('selected')
|
|
109
|
+
this.#selectedIndex = index
|
|
110
|
+
this.#rectByIndex.get(index)?.classList.add('selected')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#buildRects() {
|
|
114
|
+
this.#overlay.innerHTML = ''
|
|
115
|
+
this.#rectByIndex.clear()
|
|
116
|
+
for (const el of this.#visibleElements()) {
|
|
117
|
+
const { x, y, width, height } = el.bounds
|
|
118
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
|
119
|
+
rect.setAttribute('x', x)
|
|
120
|
+
rect.setAttribute('y', y)
|
|
121
|
+
rect.setAttribute('width', width)
|
|
122
|
+
rect.setAttribute('height', height)
|
|
123
|
+
rect.classList.add('highlight-rect')
|
|
124
|
+
if (el.index === this.#selectedIndex) rect.classList.add('selected')
|
|
125
|
+
rect.addEventListener('click', () => this.#onClickCb?.(el.index))
|
|
126
|
+
rect.addEventListener('mouseenter', () => rect.classList.add('hovered'))
|
|
127
|
+
rect.addEventListener('mouseleave', () => rect.classList.remove('hovered'))
|
|
128
|
+
this.#rectByIndex.set(el.index, rect)
|
|
129
|
+
this.#overlay.appendChild(rect)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#visibleElements() {
|
|
134
|
+
return this.#elements.filter(el =>
|
|
135
|
+
el.bounds &&
|
|
136
|
+
el.isVisible &&
|
|
137
|
+
!this.#hiddenIndices.has(el.index) &&
|
|
138
|
+
el.bounds.width > 0 &&
|
|
139
|
+
el.bounds.height > 0
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#constrainSize() {
|
|
144
|
+
if (this.#img.hidden) return
|
|
145
|
+
const cs = getComputedStyle(this.#screenshotPane)
|
|
146
|
+
const availH = this.#screenshotPane.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
|
147
|
+
this.#img.style.maxHeight = availH + 'px'
|
|
148
|
+
const vw = this.#logicalWidth || this.#img.naturalWidth
|
|
149
|
+
const vh = this.#logicalHeight || this.#img.naturalHeight
|
|
150
|
+
this.#overlay.setAttribute('width', this.#img.offsetWidth)
|
|
151
|
+
this.#overlay.setAttribute('height', this.#img.offsetHeight)
|
|
152
|
+
this.#overlay.setAttribute('viewBox', `0 0 ${vw} ${vh}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- ElementsPane ----
|
|
157
|
+
// Owns the element list: rendering rows, selection state, visibility toggles.
|
|
158
|
+
|
|
159
|
+
class ElementsPane {
|
|
160
|
+
#list
|
|
161
|
+
#onClickCb = null
|
|
162
|
+
#onToggleCb = null
|
|
163
|
+
// O(1) lookup from element index to its row element; rebuilt on each render call.
|
|
164
|
+
#rowByIndex = new Map()
|
|
165
|
+
#selectedRow = null
|
|
166
|
+
|
|
167
|
+
constructor() {
|
|
168
|
+
this.#list = document.getElementById('elements-list')
|
|
169
|
+
this.#list.setAttribute('role', 'list')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
onElementClick(cb) { this.#onClickCb = cb }
|
|
173
|
+
onToggleHidden(cb) { this.#onToggleCb = cb }
|
|
174
|
+
|
|
175
|
+
render(elements, hiddenIndices) {
|
|
176
|
+
this.#list.innerHTML = ''
|
|
177
|
+
this.#rowByIndex.clear()
|
|
178
|
+
this.#selectedRow = null
|
|
179
|
+
if (elements.length === 0) {
|
|
180
|
+
const msg = document.createElement('div')
|
|
181
|
+
msg.className = 'pane-message'
|
|
182
|
+
msg.textContent = 'No elements'
|
|
183
|
+
this.#list.appendChild(msg)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const dupes = buildDuplicateSet(elements)
|
|
187
|
+
for (const el of elements) {
|
|
188
|
+
const row = this.#buildRow(el, hiddenIndices, dupes)
|
|
189
|
+
this.#rowByIndex.set(el.index, row)
|
|
190
|
+
this.#list.appendChild(row)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setSelectedIndex(index) {
|
|
195
|
+
// O(1): deselect previous row, select new one directly via index map.
|
|
196
|
+
this.#selectedRow?.classList.remove('selected')
|
|
197
|
+
this.#selectedRow = this.#rowByIndex.get(index) ?? null
|
|
198
|
+
if (this.#selectedRow) {
|
|
199
|
+
this.#selectedRow.classList.add('selected')
|
|
200
|
+
this.#selectedRow.scrollIntoView({ block: 'nearest' })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
updateRowVisibility(index, hidden) {
|
|
205
|
+
const row = this.#rowByIndex.get(index)
|
|
206
|
+
if (!row) return
|
|
207
|
+
row.classList.toggle('element-hidden', hidden)
|
|
208
|
+
const btn = row.querySelector('.vis-btn')
|
|
209
|
+
if (btn) {
|
|
210
|
+
btn.textContent = hidden ? '○' : '◉'
|
|
211
|
+
btn.title = hidden ? 'Show on screenshot' : 'Hide from screenshot'
|
|
212
|
+
btn.setAttribute('aria-label', hidden ? 'Show on screenshot' : 'Hide from screenshot')
|
|
213
|
+
btn.setAttribute('aria-pressed', String(!hidden))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#buildRow(el, hiddenIndices, dupes) {
|
|
218
|
+
const row = document.createElement('div')
|
|
219
|
+
row.className = 'element-row'
|
|
220
|
+
row.setAttribute('role', 'listitem')
|
|
221
|
+
row.tabIndex = 0
|
|
222
|
+
row.dataset.index = el.index
|
|
223
|
+
if (!el.locator) row.classList.add('no-locator')
|
|
224
|
+
if (hiddenIndices.has(el.index)) row.classList.add('element-hidden')
|
|
225
|
+
|
|
226
|
+
const locLabel = el.locator ? locatorLabel(el.locator) : null
|
|
227
|
+
row.setAttribute('aria-label', locLabel ?? `${el.type ?? 'unknown'} (no locator)`)
|
|
228
|
+
|
|
229
|
+
const badge = document.createElement('span')
|
|
230
|
+
badge.className = `locator-badge badge-${el.locator?.kind ?? 'none'}`
|
|
231
|
+
badge.textContent = el.locator?.kind ?? 'none'
|
|
232
|
+
row.appendChild(badge)
|
|
233
|
+
|
|
234
|
+
const value = document.createElement('span')
|
|
235
|
+
value.className = 'locator-value'
|
|
236
|
+
value.textContent = locLabel ?? '(no locator)'
|
|
237
|
+
if (locLabel) value.title = locLabel
|
|
238
|
+
row.appendChild(value)
|
|
239
|
+
|
|
240
|
+
if (el.locator && dupes.has(locatorKey(el.locator))) {
|
|
241
|
+
const warn = document.createElement('span')
|
|
242
|
+
warn.className = 'duplicate-warning'
|
|
243
|
+
warn.textContent = 'dup'
|
|
244
|
+
warn.title = 'Multiple elements share this locator'
|
|
245
|
+
row.appendChild(warn)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const type = document.createElement('span')
|
|
249
|
+
type.className = 'element-type'
|
|
250
|
+
type.textContent = el.type ?? ''
|
|
251
|
+
row.appendChild(type)
|
|
252
|
+
|
|
253
|
+
const isHidden = hiddenIndices.has(el.index)
|
|
254
|
+
const visBtn = document.createElement('button')
|
|
255
|
+
visBtn.className = 'vis-btn'
|
|
256
|
+
visBtn.textContent = isHidden ? '○' : '◉'
|
|
257
|
+
visBtn.title = isHidden ? 'Show on screenshot' : 'Hide from screenshot'
|
|
258
|
+
visBtn.setAttribute('aria-label', isHidden ? 'Show on screenshot' : 'Hide from screenshot')
|
|
259
|
+
visBtn.setAttribute('aria-pressed', String(!isHidden))
|
|
260
|
+
visBtn.addEventListener('click', e => { e.stopPropagation(); this.#onToggleCb?.(el.index) })
|
|
261
|
+
row.appendChild(visBtn)
|
|
262
|
+
|
|
263
|
+
row.addEventListener('click', () => this.#onClickCb?.(el.index))
|
|
264
|
+
row.addEventListener('keydown', e => {
|
|
265
|
+
if (e.target !== row) return
|
|
266
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
267
|
+
e.preventDefault()
|
|
268
|
+
this.#onClickCb?.(el.index)
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
return row
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- Inspector ----
|
|
276
|
+
// Orchestrates device management, the refresh cycle, and shared selection/visibility state.
|
|
277
|
+
|
|
278
|
+
class Inspector {
|
|
279
|
+
#state = {
|
|
280
|
+
devices: [],
|
|
281
|
+
activeId: null,
|
|
282
|
+
elements: [],
|
|
283
|
+
selectedIndex: null,
|
|
284
|
+
logicalWidth: 0,
|
|
285
|
+
logicalHeight: 0,
|
|
286
|
+
hiddenIndices: new Set(),
|
|
287
|
+
}
|
|
288
|
+
// Persists user visibility overrides across refreshes: locatorKey -> 'hidden' | 'visible'.
|
|
289
|
+
// Pruned on each refresh to keys present in the new element list.
|
|
290
|
+
#userOverrides = new Map()
|
|
291
|
+
#autoRefreshTimer = null
|
|
292
|
+
#tickInFlight = false
|
|
293
|
+
#refreshInFlight = false
|
|
294
|
+
#connectInFlight = false
|
|
295
|
+
#consecutiveErrors = 0
|
|
296
|
+
static #MAX_CONSECUTIVE_ERRORS = 3
|
|
297
|
+
|
|
298
|
+
#screenshotPane = new ScreenshotPane()
|
|
299
|
+
#elementsPane = new ElementsPane()
|
|
300
|
+
#deviceSelect = document.getElementById('device-select')
|
|
301
|
+
#refreshBtn = document.getElementById('refresh-btn')
|
|
302
|
+
#autoRefreshToggle = document.getElementById('auto-refresh-toggle')
|
|
303
|
+
#autoRefreshInterval = document.getElementById('auto-refresh-interval')
|
|
304
|
+
#statusBar = document.getElementById('status-bar')
|
|
305
|
+
|
|
306
|
+
constructor() {
|
|
307
|
+
this.#screenshotPane.onElementClick(i => this.#selectElement(i))
|
|
308
|
+
this.#elementsPane.onElementClick(i => this.#selectElement(i))
|
|
309
|
+
this.#elementsPane.onToggleHidden(i => this.#toggleHidden(i))
|
|
310
|
+
|
|
311
|
+
this.#refreshBtn.addEventListener('click', () => this.refresh())
|
|
312
|
+
this.#deviceSelect.addEventListener('change', () => {
|
|
313
|
+
const opt = this.#deviceSelect.selectedOptions[0]
|
|
314
|
+
if (!opt?.value) return
|
|
315
|
+
const device = this.#state.devices.find(d => d.id === opt.value)
|
|
316
|
+
if (device) this.#connectDevice(device)
|
|
317
|
+
})
|
|
318
|
+
this.#autoRefreshToggle.addEventListener('change', () => this.#applyAutoRefresh())
|
|
319
|
+
this.#autoRefreshInterval.addEventListener('change', () => this.#applyAutoRefresh())
|
|
320
|
+
|
|
321
|
+
this.#loadDevices()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async refresh() {
|
|
325
|
+
if (!this.#state.activeId) return
|
|
326
|
+
if (this.#refreshInFlight) return
|
|
327
|
+
this.#refreshInFlight = true
|
|
328
|
+
this.#refreshBtn.disabled = true
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const res = await fetch('/api/inspect')
|
|
332
|
+
if (res.status === 503) return // another inspect in flight, skip this tick — no state changed yet
|
|
333
|
+
this.#setStatus('Loading...', 'loading')
|
|
334
|
+
if (this.#screenshotPane.isScreenshotHidden) {
|
|
335
|
+
this.#screenshotPane.showPlaceholder('Loading screenshot...', '', true)
|
|
336
|
+
}
|
|
337
|
+
if (res.status === 409) {
|
|
338
|
+
this.#state.activeId = null
|
|
339
|
+
this.#state.elements = []
|
|
340
|
+
this.#state.hiddenIndices = new Set()
|
|
341
|
+
this.#state.selectedIndex = null
|
|
342
|
+
this.#screenshotPane.showPlaceholder('Device disconnected', 'Select a device to continue')
|
|
343
|
+
this.#elementsPane.render([], new Set())
|
|
344
|
+
this.#screenshotPane.renderHighlights([], new Set(), null)
|
|
345
|
+
this.#setStatus('Device disconnected', 'error')
|
|
346
|
+
this.#renderDevicePicker()
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (!res.ok) {
|
|
350
|
+
const err = await res.json()
|
|
351
|
+
throw new Error(err.error ?? res.statusText)
|
|
352
|
+
}
|
|
353
|
+
const data = await res.json()
|
|
354
|
+
this.#state.elements = data.elements ?? []
|
|
355
|
+
this.#state.selectedIndex = null
|
|
356
|
+
this.#state.logicalWidth = data.screen?.width ?? 0
|
|
357
|
+
this.#state.logicalHeight = data.screen?.height ?? 0
|
|
358
|
+
|
|
359
|
+
// Prune overrides for locator keys no longer present in the new element list.
|
|
360
|
+
const liveKeys = new Set(
|
|
361
|
+
this.#state.elements.filter(el => el.locator).map(el => locatorKey(el.locator))
|
|
362
|
+
)
|
|
363
|
+
for (const k of this.#userOverrides.keys()) {
|
|
364
|
+
if (!liveKeys.has(k)) this.#userOverrides.delete(k)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this.#state.hiddenIndices = this.#computeHiddenIndices()
|
|
368
|
+
|
|
369
|
+
this.#screenshotPane.showScreenshot(data.screenshot, this.#state.logicalWidth, this.#state.logicalHeight)
|
|
370
|
+
this.#elementsPane.render(this.#state.elements, this.#state.hiddenIndices)
|
|
371
|
+
this.#screenshotPane.renderHighlights(this.#state.elements, this.#state.hiddenIndices, null)
|
|
372
|
+
this.#setStatus(`${this.#state.elements.length} elements`)
|
|
373
|
+
this.#consecutiveErrors = 0
|
|
374
|
+
} catch (err) {
|
|
375
|
+
this.#consecutiveErrors++
|
|
376
|
+
this.#setStatus('Refresh failed: ' + err.message, 'error')
|
|
377
|
+
if (this.#screenshotPane.isScreenshotHidden) {
|
|
378
|
+
this.#screenshotPane.showPlaceholder('Could not load screenshot', err.message)
|
|
379
|
+
}
|
|
380
|
+
if (this.#consecutiveErrors >= Inspector.#MAX_CONSECUTIVE_ERRORS) {
|
|
381
|
+
this.#stopAutoRefresh()
|
|
382
|
+
this.#setStatus(`Auto-refresh stopped after ${this.#consecutiveErrors} consecutive failures`, 'error')
|
|
383
|
+
}
|
|
384
|
+
} finally {
|
|
385
|
+
this.#refreshInFlight = false
|
|
386
|
+
this.#refreshBtn.disabled = false
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async #loadDevices() {
|
|
391
|
+
try {
|
|
392
|
+
await this.#fetchDevices()
|
|
393
|
+
if (this.#state.activeId) {
|
|
394
|
+
await this.refresh()
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
this.#setStatus('Could not load devices: ' + err.message, 'error')
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async #fetchDevices() {
|
|
402
|
+
const res = await fetch('/api/devices')
|
|
403
|
+
if (!res.ok) throw new Error(`Device list failed: ${res.status}`)
|
|
404
|
+
const data = await res.json()
|
|
405
|
+
const prevActiveId = this.#state.activeId
|
|
406
|
+
this.#state.devices = data.devices ?? []
|
|
407
|
+
this.#state.activeId = data.activeId ?? null
|
|
408
|
+
if (this.#state.activeId !== prevActiveId) this.#userOverrides.clear()
|
|
409
|
+
this.#renderDevicePicker()
|
|
410
|
+
if (prevActiveId && !this.#state.activeId) {
|
|
411
|
+
this.#state.elements = []
|
|
412
|
+
this.#state.selectedIndex = null
|
|
413
|
+
this.#state.hiddenIndices = new Set()
|
|
414
|
+
this.#elementsPane.render([], new Set())
|
|
415
|
+
this.#screenshotPane.showPlaceholder('Device disconnected', 'Select a device to continue')
|
|
416
|
+
this.#setStatus('Device disconnected', 'error')
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#renderDevicePicker() {
|
|
421
|
+
const currentIds = [...this.#deviceSelect.options].filter(o => o.value).map(o => o.value)
|
|
422
|
+
const newIds = this.#state.devices.map(d => d.id)
|
|
423
|
+
const sameList = currentIds.length === newIds.length && currentIds.every((id, i) => id === newIds[i])
|
|
424
|
+
if (sameList && this.#deviceSelect.value === (this.#state.activeId ?? '')) return
|
|
425
|
+
|
|
426
|
+
this.#deviceSelect.innerHTML = ''
|
|
427
|
+
if (this.#state.devices.length === 0) {
|
|
428
|
+
const opt = document.createElement('option')
|
|
429
|
+
opt.value = ''
|
|
430
|
+
opt.textContent = 'No devices connected'
|
|
431
|
+
this.#deviceSelect.appendChild(opt)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
if (!this.#state.activeId) {
|
|
435
|
+
const placeholder = document.createElement('option')
|
|
436
|
+
placeholder.value = ''
|
|
437
|
+
placeholder.textContent = 'Select a device'
|
|
438
|
+
placeholder.disabled = true
|
|
439
|
+
placeholder.selected = true
|
|
440
|
+
this.#deviceSelect.appendChild(placeholder)
|
|
441
|
+
}
|
|
442
|
+
for (const d of this.#state.devices) {
|
|
443
|
+
const opt = document.createElement('option')
|
|
444
|
+
opt.value = d.id
|
|
445
|
+
opt.dataset.platform = d.platform
|
|
446
|
+
opt.textContent = `${d.name} (${d.platform}, ${d.type})`
|
|
447
|
+
if (d.id === this.#state.activeId) opt.selected = true
|
|
448
|
+
this.#deviceSelect.appendChild(opt)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async #connectDevice(device) {
|
|
453
|
+
if (this.#connectInFlight) return
|
|
454
|
+
this.#connectInFlight = true
|
|
455
|
+
this.#setStatus('Connecting...', 'loading')
|
|
456
|
+
this.#screenshotPane.showPlaceholder('Connecting...', device.name ?? device.id, true)
|
|
457
|
+
this.#refreshBtn.disabled = true
|
|
458
|
+
this.#deviceSelect.disabled = true
|
|
459
|
+
try {
|
|
460
|
+
const res = await fetch(`/api/devices/${encodeURIComponent(device.id)}/select`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers: { 'Content-Type': 'application/json' },
|
|
463
|
+
body: JSON.stringify({ platform: device.platform }),
|
|
464
|
+
})
|
|
465
|
+
if (!res.ok) {
|
|
466
|
+
const err = await res.json()
|
|
467
|
+
throw new Error(err.error ?? res.statusText)
|
|
468
|
+
}
|
|
469
|
+
this.#state.activeId = device.id
|
|
470
|
+
this.#userOverrides.clear()
|
|
471
|
+
this.#renderDevicePicker()
|
|
472
|
+
await this.refresh()
|
|
473
|
+
} catch (err) {
|
|
474
|
+
this.#setStatus('Connect failed: ' + err.message, 'error')
|
|
475
|
+
this.#screenshotPane.showPlaceholder('Connect failed', err.message)
|
|
476
|
+
this.#renderDevicePicker()
|
|
477
|
+
} finally {
|
|
478
|
+
this.#connectInFlight = false
|
|
479
|
+
this.#deviceSelect.disabled = false
|
|
480
|
+
this.#refreshBtn.disabled = false
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#selectElement(index) {
|
|
485
|
+
this.#state.selectedIndex = index
|
|
486
|
+
this.#screenshotPane.setSelectedIndex(index)
|
|
487
|
+
this.#elementsPane.setSelectedIndex(index)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#toggleHidden(index) {
|
|
491
|
+
const el = this.#state.elements.find(el => el.index === index)
|
|
492
|
+
const key = el?.locator ? locatorKey(el.locator) : null
|
|
493
|
+
const nowHidden = this.#state.hiddenIndices.has(index)
|
|
494
|
+
// Only persist overrides for unique locator keys — shared keys would affect all duplicates.
|
|
495
|
+
const isDupe = key ? buildDuplicateSet(this.#state.elements).has(key) : false
|
|
496
|
+
|
|
497
|
+
if (nowHidden) {
|
|
498
|
+
this.#state.hiddenIndices.delete(index)
|
|
499
|
+
if (key && !isDupe) this.#userOverrides.set(key, 'visible')
|
|
500
|
+
} else {
|
|
501
|
+
this.#state.hiddenIndices.add(index)
|
|
502
|
+
if (key && !isDupe) this.#userOverrides.set(key, 'hidden')
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this.#screenshotPane.renderHighlights(this.#state.elements, this.#state.hiddenIndices, this.#state.selectedIndex)
|
|
506
|
+
this.#elementsPane.updateRowVisibility(index, this.#state.hiddenIndices.has(index))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#computeHiddenIndices() {
|
|
510
|
+
const dupes = buildDuplicateSet(this.#state.elements)
|
|
511
|
+
return new Set(
|
|
512
|
+
this.#state.elements
|
|
513
|
+
.filter(el => {
|
|
514
|
+
const key = el.locator ? locatorKey(el.locator) : null
|
|
515
|
+
const override = key && !dupes.has(key) ? this.#userOverrides.get(key) : undefined
|
|
516
|
+
if (override === 'visible') return false
|
|
517
|
+
if (override === 'hidden') return true
|
|
518
|
+
return el.locator?.kind === 'testId' && DEFAULT_HIDDEN_TESTIDS.has(el.locator.value)
|
|
519
|
+
})
|
|
520
|
+
.map(el => el.index)
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#applyAutoRefresh() {
|
|
525
|
+
clearInterval(this.#autoRefreshTimer)
|
|
526
|
+
this.#autoRefreshTimer = null
|
|
527
|
+
this.#autoRefreshInterval.disabled = !this.#autoRefreshToggle.checked
|
|
528
|
+
this.#consecutiveErrors = 0 // reset when user manually reconfigures auto-refresh
|
|
529
|
+
if (this.#autoRefreshToggle.checked) {
|
|
530
|
+
const ms = Number(this.#autoRefreshInterval.value)
|
|
531
|
+
this.#autoRefreshTimer = setInterval(async () => {
|
|
532
|
+
if (this.#tickInFlight) return
|
|
533
|
+
this.#tickInFlight = true
|
|
534
|
+
try {
|
|
535
|
+
await this.#fetchDevices().catch(() => {})
|
|
536
|
+
await this.refresh()
|
|
537
|
+
} finally {
|
|
538
|
+
this.#tickInFlight = false
|
|
539
|
+
}
|
|
540
|
+
}, ms)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Called from the error path only — unregisters the timer and unchecks the toggle.
|
|
545
|
+
#stopAutoRefresh() {
|
|
546
|
+
clearInterval(this.#autoRefreshTimer)
|
|
547
|
+
this.#autoRefreshTimer = null
|
|
548
|
+
this.#autoRefreshToggle.checked = false
|
|
549
|
+
this.#autoRefreshInterval.disabled = true
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#setStatus(msg, type = '') {
|
|
553
|
+
this.#statusBar.textContent = msg
|
|
554
|
+
this.#statusBar.className = type
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---- Theme ----
|
|
559
|
+
|
|
560
|
+
function applyTheme(name) {
|
|
561
|
+
document.documentElement.setAttribute('data-theme', name)
|
|
562
|
+
localStorage.setItem('mobilewright-inspector-theme', name)
|
|
563
|
+
const sel = document.getElementById('theme-select')
|
|
564
|
+
if (sel) sel.value = name
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
document.getElementById('theme-select')?.addEventListener('change', e => applyTheme(e.target.value))
|
|
568
|
+
|
|
569
|
+
// ---- Bootstrap ----
|
|
570
|
+
|
|
571
|
+
applyTheme(localStorage.getItem('mobilewright-inspector-theme') || 'void')
|
|
572
|
+
new Inspector()
|