@flrande/bak-extension 0.6.14 → 0.6.16

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/src/content.ts CHANGED
@@ -1936,7 +1936,7 @@ function describeTables(): TableHandle[] {
1936
1936
  for (const [index, table] of htmlTables.entries()) {
1937
1937
  const handle: TableHandle = {
1938
1938
  id: buildTableId(table.closest('.dataTables_wrapper') ? 'dataTables' : 'html', index),
1939
- name: (table.getAttribute('aria-label') || table.getAttribute('data-testid') || table.id || `table-${index + 1}`).trim(),
1939
+ label: (table.getAttribute('aria-label') || table.getAttribute('data-testid') || table.id || `table-${index + 1}`).trim(),
1940
1940
  kind: table.closest('.dataTables_wrapper') ? 'dataTables' : 'html',
1941
1941
  selector: table.id ? `#${table.id}` : undefined,
1942
1942
  rowCount: table.querySelectorAll('tbody tr').length || table.querySelectorAll('tr').length,
@@ -1949,7 +1949,7 @@ function describeTables(): TableHandle[] {
1949
1949
  const kind: TableHandle['kind'] = grid.className.includes('ag-') ? 'ag-grid' : 'aria-grid';
1950
1950
  const handle: TableHandle = {
1951
1951
  id: buildTableId(kind, index),
1952
- name: (grid.getAttribute('aria-label') || grid.getAttribute('data-testid') || grid.id || `grid-${index + 1}`).trim(),
1952
+ label: (grid.getAttribute('aria-label') || grid.getAttribute('data-testid') || grid.id || `grid-${index + 1}`).trim(),
1953
1953
  kind,
1954
1954
  selector: grid.id ? `#${grid.id}` : undefined,
1955
1955
  rowCount: gridRowNodes(grid).length,
@@ -1962,7 +1962,13 @@ function describeTables(): TableHandle[] {
1962
1962
 
1963
1963
  function resolveTable(handleId: string): { table: TableHandle; element: Element | null } | null {
1964
1964
  const tables = describeTables();
1965
- const handle = tables.find((candidate) => candidate.id === handleId);
1965
+ const normalizedHandleId = handleId.trim().toLowerCase();
1966
+ const handle = tables.find((candidate) => {
1967
+ if (candidate.id === handleId) {
1968
+ return true;
1969
+ }
1970
+ return typeof candidate.label === 'string' && candidate.label.trim().toLowerCase() === normalizedHandleId;
1971
+ });
1966
1972
  if (!handle) {
1967
1973
  return null;
1968
1974
  }
@@ -530,9 +530,9 @@ function scoreSourceMapping(table: TableAnalysis, source: DynamicSourceAnalysis,
530
530
  }
531
531
 
532
532
  const explicitReferenceHit =
533
- table.table.name.toLowerCase().includes(source.source.label.toLowerCase()) ||
533
+ table.table.label.toLowerCase().includes(source.source.label.toLowerCase()) ||
534
534
  (table.table.selector ?? '').toLowerCase().includes(source.source.label.toLowerCase()) ||
535
- source.source.label.toLowerCase().includes(table.table.name.toLowerCase());
535
+ source.source.label.toLowerCase().includes(table.table.label.toLowerCase());
536
536
  if (explicitReferenceHit) {
537
537
  basis.push({
538
538
  type: 'explicitReference',
@@ -1,4 +1,4 @@
1
- import type { NetworkEntry } from '@flrande/bak-protocol';
1
+ import type { NetworkEntry, NetworkSearchResult } from '@flrande/bak-protocol';
2
2
  import { redactHeaderMap, redactTransportText } from './privacy.js';
3
3
  import { EXTENSION_VERSION } from './version.js';
4
4
 
@@ -16,12 +16,18 @@ interface DebuggerTarget {
16
16
  interface TabCaptureState {
17
17
  attached: boolean;
18
18
  attachError: string | null;
19
- entries: NetworkEntry[];
20
- entriesById: Map<string, NetworkEntry>;
19
+ entries: CapturedNetworkEntry[];
20
+ entriesById: Map<string, CapturedNetworkEntry>;
21
21
  requestIdToEntryId: Map<string, string>;
22
22
  lastTouchedAt: number;
23
23
  }
24
24
 
25
+ interface CapturedNetworkEntry extends NetworkEntry {
26
+ rawRequestHeaders?: Record<string, string>;
27
+ rawRequestBody?: string;
28
+ rawRequestBodyTruncated?: boolean;
29
+ }
30
+
25
31
  const captures = new Map<number, TabCaptureState>();
26
32
 
27
33
  function getState(tabId: number): TabCaptureState {
@@ -121,7 +127,25 @@ function isTextualContentType(contentType: string | undefined): boolean {
121
127
  );
122
128
  }
123
129
 
124
- function pushEntry(state: TabCaptureState, entry: NetworkEntry, requestId: string): void {
130
+ function sanitizeEntry(entry: CapturedNetworkEntry): NetworkEntry {
131
+ const { rawRequestHeaders, rawRequestBody, rawRequestBodyTruncated, ...publicEntry } = entry;
132
+ void rawRequestHeaders;
133
+ void rawRequestBody;
134
+ void rawRequestBodyTruncated;
135
+ return {
136
+ ...publicEntry,
137
+ requestHeaders:
138
+ typeof entry.requestHeaders === 'object' && entry.requestHeaders !== null
139
+ ? { ...entry.requestHeaders }
140
+ : undefined,
141
+ responseHeaders:
142
+ typeof entry.responseHeaders === 'object' && entry.responseHeaders !== null
143
+ ? { ...entry.responseHeaders }
144
+ : undefined
145
+ };
146
+ }
147
+
148
+ function pushEntry(state: TabCaptureState, entry: CapturedNetworkEntry, requestId: string): void {
125
149
  state.entries.push(entry);
126
150
  state.entriesById.set(entry.id, entry);
127
151
  state.requestIdToEntryId.set(requestId, entry.id);
@@ -135,7 +159,7 @@ function pushEntry(state: TabCaptureState, entry: NetworkEntry, requestId: strin
135
159
  }
136
160
  }
137
161
 
138
- function entryForRequest(tabId: number, requestId: string): NetworkEntry | null {
162
+ function entryForRequest(tabId: number, requestId: string): CapturedNetworkEntry | null {
139
163
  const state = captures.get(tabId);
140
164
  if (!state) {
141
165
  return null;
@@ -206,9 +230,10 @@ function upsertRequest(tabId: number, params: Record<string, unknown>): void {
206
230
  return;
207
231
  }
208
232
  const request = typeof params.request === 'object' && params.request !== null ? (params.request as Record<string, unknown>) : {};
209
- const headers = redactHeaderMap(normalizeHeaders(request.headers));
233
+ const rawHeaders = normalizeHeaders(request.headers);
234
+ const headers = redactHeaderMap(rawHeaders);
210
235
  const truncatedRequest = truncateText(typeof request.postData === 'string' ? request.postData : undefined, DEFAULT_BODY_BYTES);
211
- const entry: NetworkEntry = {
236
+ const entry: CapturedNetworkEntry = {
212
237
  id: `net_${tabId}_${requestId}`,
213
238
  url: typeof request.url === 'string' ? request.url : '',
214
239
  method: typeof request.method === 'string' ? request.method : 'GET',
@@ -230,6 +255,9 @@ function upsertRequest(tabId: number, params: Record<string, unknown>): void {
230
255
  requestHeaders: headers,
231
256
  requestBodyPreview: truncatedRequest.text ? redactTransportText(truncatedRequest.text) : undefined,
232
257
  requestBodyTruncated: truncatedRequest.truncated,
258
+ rawRequestHeaders: rawHeaders,
259
+ rawRequestBody: typeof request.postData === 'string' ? request.postData : undefined,
260
+ rawRequestBodyTruncated: false,
233
261
  initiatorUrl:
234
262
  typeof params.initiator === 'object' &&
235
263
  params.initiator !== null &&
@@ -378,13 +406,43 @@ export function listNetworkEntries(
378
406
  .filter((entry) => entryMatchesFilters(entry, filters))
379
407
  .slice(-limit)
380
408
  .reverse()
381
- .map((entry) => ({ ...entry }));
409
+ .map((entry) => sanitizeEntry(entry));
382
410
  }
383
411
 
384
412
  export function getNetworkEntry(tabId: number, id: string): NetworkEntry | null {
385
413
  const state = getState(tabId);
386
414
  const entry = state.entriesById.get(id);
387
- return entry ? { ...entry } : null;
415
+ return entry ? sanitizeEntry(entry) : null;
416
+ }
417
+
418
+ export function getReplayableNetworkRequest(
419
+ tabId: number,
420
+ id: string
421
+ ): {
422
+ entry: NetworkEntry;
423
+ headers?: Record<string, string>;
424
+ body?: string;
425
+ contentType?: string;
426
+ degradedReason?: string;
427
+ } | null {
428
+ const state = getState(tabId);
429
+ const entry = state.entriesById.get(id);
430
+ if (!entry) {
431
+ return null;
432
+ }
433
+ const publicEntry = sanitizeEntry(entry);
434
+ if (entry.rawRequestBodyTruncated === true) {
435
+ return {
436
+ entry: publicEntry,
437
+ degradedReason: 'live replay unavailable because the captured request body was truncated in memory'
438
+ };
439
+ }
440
+ return {
441
+ entry: publicEntry,
442
+ headers: entry.rawRequestHeaders ? { ...entry.rawRequestHeaders } : undefined,
443
+ body: entry.rawRequestBody,
444
+ contentType: headerValue(entry.rawRequestHeaders, 'content-type')
445
+ };
388
446
  }
389
447
 
390
448
  export async function waitForNetworkEntry(
@@ -405,7 +463,7 @@ export async function waitForNetworkEntry(
405
463
  const nextState = getState(tabId);
406
464
  const matched = nextState.entries.find((entry) => !seenIds.has(entry.id) && entryMatchesFilters(entry, filters));
407
465
  if (matched) {
408
- return { ...matched };
466
+ return sanitizeEntry(matched);
409
467
  }
410
468
  await new Promise((resolve) => setTimeout(resolve, 75));
411
469
  }
@@ -415,9 +473,10 @@ export async function waitForNetworkEntry(
415
473
  };
416
474
  }
417
475
 
418
- export function searchNetworkEntries(tabId: number, pattern: string, limit = 50): NetworkEntry[] {
476
+ export function searchNetworkEntries(tabId: number, pattern: string, limit = 50): NetworkSearchResult {
477
+ const state = getState(tabId);
419
478
  const normalized = pattern.toLowerCase();
420
- return listNetworkEntries(tabId, { limit: Math.max(limit, 1) }).filter((entry) => {
479
+ const matchedEntries = state.entries.filter((entry) => {
421
480
  const headerText = JSON.stringify({
422
481
  requestHeaders: entry.requestHeaders,
423
482
  responseHeaders: entry.responseHeaders
@@ -429,6 +488,25 @@ export function searchNetworkEntries(tabId: number, pattern: string, limit = 50)
429
488
  headerText.includes(normalized)
430
489
  );
431
490
  });
491
+ const scannedEntries = state.entries.filter((entry) => entryMatchesFilters(entry, {}));
492
+ const toCoverage = (
493
+ entries: CapturedNetworkEntry[],
494
+ key: 'requestBodyPreview' | 'responseBodyPreview',
495
+ truncatedKey: 'requestBodyTruncated' | 'responseBodyTruncated'
496
+ ) => ({
497
+ full: entries.filter((entry) => typeof entry[key] === 'string' && entry[truncatedKey] !== true).length,
498
+ partial: entries.filter((entry) => typeof entry[key] === 'string' && entry[truncatedKey] === true).length,
499
+ none: entries.filter((entry) => typeof entry[key] !== 'string').length
500
+ });
501
+ return {
502
+ entries: matchedEntries.slice(-Math.max(limit, 1)).reverse().map((entry) => sanitizeEntry(entry)),
503
+ scanned: scannedEntries.length,
504
+ matched: matchedEntries.length,
505
+ bodyCoverage: {
506
+ request: toCoverage(scannedEntries, 'requestBodyPreview', 'requestBodyTruncated'),
507
+ response: toCoverage(scannedEntries, 'responseBodyPreview', 'responseBodyTruncated')
508
+ }
509
+ };
432
510
  }
433
511
 
434
512
  export function latestNetworkTimestamp(tabId: number): number | null {
package/src/popup.ts CHANGED
@@ -21,6 +21,7 @@ const sessionSummaryEl = document.getElementById('sessionSummary') as HTMLDivEle
21
21
  const sessionCardsEl = document.getElementById('sessionCards') as HTMLDivElement;
22
22
 
23
23
  type PopupTone = 'neutral' | 'success' | 'warning' | 'error';
24
+ type PopupSessionBindingStatus = 'attached' | 'window-only' | 'detached';
24
25
 
25
26
  interface PopupState {
26
27
  ok: boolean;
@@ -42,6 +43,7 @@ interface PopupState {
42
43
  sessionBindings: {
43
44
  count: number;
44
45
  attachedCount: number;
46
+ windowOnlyCount: number;
45
47
  detachedCount: number;
46
48
  tabCount: number;
47
49
  items: Array<{
@@ -53,7 +55,7 @@ interface PopupState {
53
55
  activeTabUrl: string | null;
54
56
  windowId: number | null;
55
57
  groupId: number | null;
56
- detached: boolean;
58
+ status: PopupSessionBindingStatus;
57
59
  lastBindingUpdateAt: number | null;
58
60
  lastBindingUpdateReason: string | null;
59
61
  }>;
@@ -158,6 +160,8 @@ function describeStatus(state: PopupState): StatusDescriptor {
158
160
  const body =
159
161
  state.sessionBindings.detachedCount > 0
160
162
  ? `${pluralize(state.sessionBindings.detachedCount, 'remembered session')} are detached. Check the cards below before you continue browser work.`
163
+ : state.sessionBindings.windowOnlyCount > 0
164
+ ? `${pluralize(state.sessionBindings.windowOnlyCount, 'remembered session')} still point at a live window, but do not own tabs yet.`
161
165
  : 'The extension bridge is healthy and ready for CLI-driven browser work.';
162
166
  return {
163
167
  badge: 'Ready',
@@ -169,6 +173,11 @@ function describeStatus(state: PopupState): StatusDescriptor {
169
173
  'Resume or recreate detached work from the bak CLI before sending new page commands.',
170
174
  'Use the Sessions panel below to confirm which remembered session lost its owned tabs.'
171
175
  ]
176
+ : state.sessionBindings.windowOnlyCount > 0
177
+ ? [
178
+ 'New bak page commands can reuse those remembered windows without disturbing your human tabs.',
179
+ 'Use the Sessions panel below to confirm which binding is only waiting for a new owned tab.'
180
+ ]
172
181
  : [
173
182
  'Start browser work from the bak CLI.',
174
183
  'Use Reconnect bridge only when you intentionally changed token or port settings.'
@@ -311,6 +320,7 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
311
320
  sessionSummaryEl.textContent =
312
321
  `${pluralize(state.count, 'session')}, ` +
313
322
  `${pluralize(state.attachedCount, 'attached binding')}, ` +
323
+ `${pluralize(state.windowOnlyCount, 'window-only binding')}, ` +
314
324
  `${pluralize(state.detachedCount, 'detached binding')}, ` +
315
325
  `${pluralize(state.tabCount, 'tracked tab')}`;
316
326
  }
@@ -328,7 +338,7 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
328
338
  for (const item of state.items) {
329
339
  const card = document.createElement('section');
330
340
  card.className = 'session-card';
331
- card.dataset.detached = item.detached ? 'true' : 'false';
341
+ card.dataset.status = item.status;
332
342
 
333
343
  const header = document.createElement('div');
334
344
  header.className = 'session-card-header';
@@ -348,19 +358,24 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
348
358
 
349
359
  const badge = document.createElement('span');
350
360
  badge.className = 'session-badge';
351
- badge.dataset.detached = item.detached ? 'true' : 'false';
352
- badge.textContent = item.detached ? 'Detached' : 'Attached';
361
+ badge.dataset.status = item.status;
362
+ badge.textContent = item.status === 'attached' ? 'Attached' : item.status === 'window-only' ? 'Window only' : 'Detached';
353
363
 
354
364
  header.append(titleWrap, badge);
355
365
 
356
366
  const activeTitle = document.createElement('div');
357
367
  activeTitle.className = 'session-active-title';
358
- activeTitle.textContent = item.activeTabTitle ? truncate(item.activeTabTitle, 72) : 'No active tab title';
368
+ activeTitle.textContent =
369
+ item.activeTabTitle
370
+ ? truncate(item.activeTabTitle, 72)
371
+ : item.status === 'window-only'
372
+ ? 'No owned tab yet'
373
+ : 'No active tab title';
359
374
  activeTitle.title = item.activeTabTitle ?? '';
360
375
 
361
376
  const activeUrl = document.createElement('div');
362
377
  activeUrl.className = 'session-active-url';
363
- activeUrl.textContent = formatUrl(item.activeTabUrl);
378
+ activeUrl.textContent = item.status === 'window-only' && !item.activeTabUrl ? 'Next bak page command can reuse this window' : formatUrl(item.activeTabUrl);
364
379
  activeUrl.title = item.activeTabUrl ?? '';
365
380
 
366
381
  const meta = document.createElement('div');
@@ -380,9 +395,12 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
380
395
 
381
396
  const footer = document.createElement('div');
382
397
  footer.className = 'session-card-footer';
383
- footer.textContent = item.detached
384
- ? 'bak still remembers this session, but its owned tabs or window are missing.'
385
- : 'The saved binding still points at live browser tabs.';
398
+ footer.textContent =
399
+ item.status === 'attached'
400
+ ? 'The saved binding still points at live browser tabs.'
401
+ : item.status === 'window-only'
402
+ ? 'bak still remembers the target window for this session, but it does not own any live tabs right now.'
403
+ : 'bak still remembers this session, but its owned tabs or window are missing.';
386
404
 
387
405
  card.append(header, activeTitle, activeUrl, meta, footer);
388
406
  sessionCardsEl.appendChild(card);
@@ -18,10 +18,16 @@ function isSessionBindingRecord(value: unknown): value is SessionBindingRecord {
18
18
  }
19
19
 
20
20
  function cloneSessionBindingRecord(state: SessionBindingRecord): SessionBindingRecord {
21
- return {
21
+ const cloned: SessionBindingRecord = {
22
22
  ...state,
23
23
  tabIds: [...state.tabIds]
24
24
  };
25
+ if (cloned.tabIds.length === 0) {
26
+ cloned.groupId = null;
27
+ cloned.activeTabId = null;
28
+ cloned.primaryTabId = null;
29
+ }
30
+ return cloned;
25
31
  }
26
32
 
27
33
  function normalizeSessionBindingRecordMap(value: unknown): { found: boolean; map: Record<string, SessionBindingRecord> } {
@@ -482,10 +482,10 @@ class SessionBindingManager {
482
482
  };
483
483
  }
484
484
 
485
- async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
486
- const binding = await this.inspectBinding(bindingId);
487
- if (!binding) {
488
- throw new Error(`Binding ${bindingId} does not exist`);
485
+ async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
486
+ const binding = await this.inspectBinding(bindingId);
487
+ if (!binding) {
488
+ throw new Error(`Binding ${bindingId} does not exist`);
489
489
  }
490
490
  const resolvedTabId =
491
491
  typeof tabId === 'number'
@@ -505,13 +505,43 @@ class SessionBindingManager {
505
505
  closedTabId: resolvedTabId
506
506
  };
507
507
  }
508
-
509
- const tabs = await this.readLooseTrackedTabs(remainingTabIds);
510
- const nextPrimaryTabId =
511
- binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
512
- const nextActiveTabId =
513
- binding.activeTabId === resolvedTabId
514
- ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
508
+
509
+ const tabs = await this.readLooseTrackedTabs(remainingTabIds);
510
+ if (tabs.length === 0) {
511
+ const liveWindow = binding.windowId !== null ? await this.waitForWindow(binding.windowId, 300) : null;
512
+ if (!liveWindow) {
513
+ await this.storage.delete(binding.id);
514
+ return {
515
+ binding: null,
516
+ closedTabId: resolvedTabId
517
+ };
518
+ }
519
+
520
+ const nextState: SessionBindingRecord = {
521
+ id: binding.id,
522
+ label: binding.label,
523
+ color: binding.color,
524
+ windowId: liveWindow.id,
525
+ groupId: null,
526
+ tabIds: [],
527
+ activeTabId: null,
528
+ primaryTabId: null
529
+ };
530
+ await this.storage.save(nextState);
531
+ return {
532
+ binding: {
533
+ ...nextState,
534
+ tabs: []
535
+ },
536
+ closedTabId: resolvedTabId
537
+ };
538
+ }
539
+
540
+ const nextPrimaryTabId =
541
+ binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
542
+ const nextActiveTabId =
543
+ binding.activeTabId === resolvedTabId
544
+ ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
515
545
  : binding.activeTabId;
516
546
  const nextState: SessionBindingRecord = {
517
547
  id: binding.id,