tabctl 0.6.0-alpha.9 → 0.6.0-rc.10

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.
@@ -3,7 +3,11 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.delay = delay;
5
5
  exports.executeWithTimeout = executeWithTimeout;
6
+ exports.executeWithTimeoutDetailed = executeWithTimeoutDetailed;
6
7
  exports.extractPageMeta = extractPageMeta;
8
+ exports.extractPageMarkdown = extractPageMarkdown;
9
+ exports.extractPageHtml = extractPageHtml;
10
+ exports.probePageQuiescence = probePageQuiescence;
7
11
  exports.extractSelectorSignal = extractSelectorSignal;
8
12
  function delay(ms) {
9
13
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -32,6 +36,29 @@ async function executeWithTimeout(tabId, timeoutMs, func, args = []) {
32
36
  return null;
33
37
  }
34
38
  }
39
+ async function executeWithTimeoutDetailed(tabId, timeoutMs, func, args = []) {
40
+ const execPromise = chrome.scripting.executeScript({
41
+ target: { tabId },
42
+ func,
43
+ args,
44
+ }).then((result) => {
45
+ if (!result || !Array.isArray(result)) {
46
+ return { kind: "ok", value: null };
47
+ }
48
+ const [{ result: value }] = result;
49
+ return { kind: "ok", value: value ?? null };
50
+ }).catch((err) => ({
51
+ kind: "error",
52
+ message: err instanceof Error ? err.message : String(err),
53
+ }));
54
+ const timeoutPromise = new Promise((resolve) => {
55
+ const handle = setTimeout(() => {
56
+ clearTimeout(handle);
57
+ resolve({ kind: "timeout" });
58
+ }, timeoutMs);
59
+ });
60
+ return Promise.race([execPromise, timeoutPromise]);
61
+ }
35
62
  async function extractPageMeta(tabId, timeoutMs, descriptionMaxLength) {
36
63
  const result = await executeWithTimeout(tabId, timeoutMs, () => {
37
64
  const pickContent = (selector) => {
@@ -61,6 +88,186 @@ async function extractPageMeta(tabId, timeoutMs, descriptionMaxLength) {
61
88
  h1: (meta.h1 || "").slice(0, descriptionMaxLength),
62
89
  };
63
90
  }
91
+ async function extractPageMarkdown(tabId, timeoutMs, maxHtmlChars) {
92
+ const result = await executeWithTimeout(tabId, timeoutMs, (cap) => {
93
+ const raw = document.documentElement?.outerHTML || "";
94
+ return raw.length > cap ? raw.slice(0, cap) : raw;
95
+ }, [maxHtmlChars]);
96
+ return typeof result === "string" ? result : "";
97
+ }
98
+ function injectionStatus(message) {
99
+ const lower = message.toLowerCase();
100
+ if (lower.includes("cannot access") || lower.includes("permission") || lower.includes("extensions gallery")) {
101
+ return "PROTECTED";
102
+ }
103
+ return "INJECTION_FAILED";
104
+ }
105
+ async function extractPageHtml(tabId, timeoutMs, maxHtmlChars) {
106
+ const result = await executeWithTimeoutDetailed(tabId, timeoutMs, (cap) => {
107
+ const raw = document.documentElement?.outerHTML || "";
108
+ const text = document.body?.innerText || document.documentElement?.textContent || "";
109
+ return {
110
+ html: raw.length > cap ? raw.slice(0, cap) : raw,
111
+ sourceHtmlChars: raw.length,
112
+ sourceTextChars: text.length,
113
+ documentReadyState: document.readyState,
114
+ truncatedHtml: raw.length > cap,
115
+ };
116
+ }, [maxHtmlChars]);
117
+ if (result.kind === "timeout") {
118
+ return {
119
+ status: "TIMED_OUT",
120
+ html: "",
121
+ sourceHtmlChars: 0,
122
+ sourceTextChars: 0,
123
+ documentReadyState: null,
124
+ truncatedHtml: false,
125
+ error: `Timed out after ${timeoutMs}ms`,
126
+ };
127
+ }
128
+ if (result.kind === "error") {
129
+ return {
130
+ status: injectionStatus(result.message),
131
+ html: "",
132
+ sourceHtmlChars: 0,
133
+ sourceTextChars: 0,
134
+ documentReadyState: null,
135
+ truncatedHtml: false,
136
+ error: result.message,
137
+ };
138
+ }
139
+ if (!result.value) {
140
+ return {
141
+ status: "EXTRACTION_FAILED",
142
+ html: "",
143
+ sourceHtmlChars: 0,
144
+ sourceTextChars: 0,
145
+ documentReadyState: null,
146
+ truncatedHtml: false,
147
+ error: "Content script returned no page HTML payload",
148
+ };
149
+ }
150
+ const status = result.value.documentReadyState === "loading" ? "NOT_LOADED" : "READ";
151
+ return {
152
+ status,
153
+ ...result.value,
154
+ error: null,
155
+ };
156
+ }
157
+ async function probePageQuiescence(tabId, timeoutMs, sampleWindowMs) {
158
+ const result = await executeWithTimeoutDetailed(tabId, timeoutMs, async (rawWindowMs) => {
159
+ const startedAt = Date.now();
160
+ const windowMs = Math.max(50, Math.min(Number(rawWindowMs) || 350, 1_500));
161
+ const htmlCap = 250_000;
162
+ const sample = () => {
163
+ const root = document.documentElement;
164
+ const bodyText = document.body?.innerText || root?.textContent || "";
165
+ const html = root?.outerHTML || "";
166
+ const resources = typeof performance !== "undefined" && typeof performance.getEntriesByType === "function"
167
+ ? performance.getEntriesByType("resource").length
168
+ : null;
169
+ return {
170
+ textChars: bodyText.length,
171
+ htmlChars: Math.min(html.length, htmlCap),
172
+ domElements: document.getElementsByTagName("*").length,
173
+ resourceCount: resources,
174
+ };
175
+ };
176
+ const waitForIdleWindow = () => new Promise((resolve) => {
177
+ const win = window;
178
+ const done = () => setTimeout(resolve, windowMs);
179
+ if (typeof win.requestIdleCallback === "function") {
180
+ let resolved = false;
181
+ const finish = () => {
182
+ if (resolved) {
183
+ return;
184
+ }
185
+ resolved = true;
186
+ done();
187
+ };
188
+ const fallback = setTimeout(finish, windowMs + 100);
189
+ win.requestIdleCallback(() => {
190
+ clearTimeout(fallback);
191
+ finish();
192
+ }, { timeout: windowMs });
193
+ return;
194
+ }
195
+ setTimeout(resolve, windowMs);
196
+ });
197
+ try {
198
+ const readyState = document.readyState;
199
+ if (readyState !== "interactive" && readyState !== "complete") {
200
+ return {
201
+ quiet: false,
202
+ reason: "not-ready",
203
+ documentReadyState: readyState,
204
+ before: null,
205
+ after: null,
206
+ elapsedMs: Date.now() - startedAt,
207
+ error: null,
208
+ };
209
+ }
210
+ const before = sample();
211
+ await waitForIdleWindow();
212
+ const after = sample();
213
+ const stable = before.textChars === after.textChars
214
+ && before.htmlChars === after.htmlChars
215
+ && before.domElements === after.domElements
216
+ && before.resourceCount === after.resourceCount;
217
+ return {
218
+ quiet: stable,
219
+ reason: stable ? "stable" : "changed",
220
+ documentReadyState: document.readyState,
221
+ before,
222
+ after,
223
+ elapsedMs: Date.now() - startedAt,
224
+ error: null,
225
+ };
226
+ }
227
+ catch (err) {
228
+ return {
229
+ quiet: false,
230
+ reason: "probe-error",
231
+ documentReadyState: typeof document !== "undefined" ? document.readyState : null,
232
+ before: null,
233
+ after: null,
234
+ elapsedMs: Date.now() - startedAt,
235
+ error: err instanceof Error ? err.message : String(err),
236
+ };
237
+ }
238
+ }, [sampleWindowMs]);
239
+ if (result.kind === "timeout") {
240
+ return {
241
+ quiet: false,
242
+ reason: "timed-out",
243
+ documentReadyState: null,
244
+ before: null,
245
+ after: null,
246
+ elapsedMs: timeoutMs,
247
+ error: `Timed out after ${timeoutMs}ms`,
248
+ };
249
+ }
250
+ if (result.kind === "error") {
251
+ return {
252
+ quiet: false,
253
+ reason: "injection-error",
254
+ documentReadyState: null,
255
+ before: null,
256
+ after: null,
257
+ elapsedMs: 0,
258
+ error: result.message,
259
+ };
260
+ }
261
+ return result.value ?? {
262
+ quiet: false,
263
+ reason: "no-result",
264
+ documentReadyState: null,
265
+ before: null,
266
+ after: null,
267
+ elapsedMs: 0,
268
+ error: "Content script returned no quiescence probe payload",
269
+ };
270
+ }
64
271
  async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLength) {
65
272
  if (!specs.length) {
66
273
  return null;
@@ -70,6 +277,9 @@ async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLe
70
277
  const missing = [];
71
278
  const errors = {};
72
279
  const hints = {};
280
+ const stringCap = typeof maxLen === "number" && maxLen > 0 ? maxLen : 500;
281
+ const htmlCap = typeof maxLen === "number" && maxLen > 0 ? maxLen : 4096;
282
+ const normalizeStringValue = (value, cap) => value.replace(/\s+/g, " ").trim().slice(0, cap);
73
283
  for (const raw of rawSpecs) {
74
284
  const selector = typeof raw.selector === "string" ? raw.selector : "";
75
285
  if (!selector) {
@@ -82,6 +292,9 @@ async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLe
82
292
  const textMode = typeof raw.textMode === "string" ? raw.textMode.trim().toLowerCase() : "";
83
293
  const normalizedTextMode = textMode === "includes" ? "contains" : textMode;
84
294
  const textModes = new Set(["", "contains", "exact", "starts-with"]);
295
+ const styleProps = Array.isArray(raw.styleProps)
296
+ ? raw.styleProps.filter((prop) => typeof prop === "string").map((prop) => prop.trim()).filter(Boolean)
297
+ : [];
85
298
  if (!textModes.has(normalizedTextMode)) {
86
299
  errors[name] = `Unsupported textMode: ${textMode || "unknown"}`;
87
300
  hints[name] = "Use textMode: contains | exact | starts-with";
@@ -89,16 +302,6 @@ async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLe
89
302
  }
90
303
  try {
91
304
  const elements = Array.from(document.querySelectorAll(selector));
92
- if (!elements.length) {
93
- missing.push(name);
94
- if (selector.includes(":contains(")) {
95
- hints[name] = "CSS :contains() is not supported; use selector text filters or a different selector.";
96
- }
97
- else {
98
- hints[name] = "No matches found; capture a screenshot for context or adjust the selector.";
99
- }
100
- continue;
101
- }
102
305
  const matchesText = (el) => {
103
306
  if (!text) {
104
307
  return true;
@@ -113,43 +316,103 @@ async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLe
113
316
  return content.includes(text);
114
317
  };
115
318
  const filtered = text ? elements.filter(matchesText) : elements;
319
+ if (attr === "count") {
320
+ values[name] = filtered.length;
321
+ continue;
322
+ }
323
+ if (!elements.length) {
324
+ missing.push(name);
325
+ if (selector.includes(":contains(")) {
326
+ hints[name] = "CSS :contains() is not supported; use selector text filters or a different selector.";
327
+ }
328
+ else {
329
+ hints[name] = "No matches found; capture a screenshot for context or adjust the selector.";
330
+ }
331
+ continue;
332
+ }
116
333
  if (!filtered.length) {
117
334
  missing.push(name);
118
335
  hints[name] = "Selector matched elements, but none matched the text filter; capture a screenshot for context or adjust text/textMode.";
119
336
  continue;
120
337
  }
121
338
  const getValue = (el) => {
122
- let value = "";
123
339
  if (attr === "text") {
124
- value = el.textContent || "";
340
+ return normalizeStringValue(el.textContent || "", stringCap);
341
+ }
342
+ if (attr === "html") {
343
+ return normalizeStringValue(el.outerHTML || "", htmlCap);
125
344
  }
126
- else if (attr === "href-url" || attr === "src-url") {
345
+ if (attr === "href-url" || attr === "src-url") {
127
346
  const rawValue = el.getAttribute(attr === "href-url" ? "href" : "src") || "";
128
347
  if (!rawValue) {
129
- value = "";
348
+ return "";
130
349
  }
131
- else {
132
- try {
133
- const resolved = new URL(rawValue, document.baseURI);
134
- if (resolved.protocol === "http:" || resolved.protocol === "https:") {
135
- value = resolved.toString();
136
- }
137
- else {
138
- value = "";
139
- }
140
- }
141
- catch {
142
- value = "";
350
+ try {
351
+ const resolved = new URL(rawValue, document.baseURI);
352
+ if (resolved.protocol === "http:" || resolved.protocol === "https:") {
353
+ return normalizeStringValue(resolved.toString(), stringCap);
143
354
  }
355
+ return "";
356
+ }
357
+ catch {
358
+ return "";
144
359
  }
145
360
  }
146
- else {
147
- value = el.getAttribute(attr) || "";
361
+ if (attr === "value") {
362
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
363
+ return el.value;
364
+ }
365
+ return null;
366
+ }
367
+ if (attr === "box") {
368
+ const rect = el.getBoundingClientRect();
369
+ return {
370
+ x: rect.x,
371
+ y: rect.y,
372
+ width: rect.width,
373
+ height: rect.height,
374
+ top: rect.top,
375
+ right: rect.right,
376
+ bottom: rect.bottom,
377
+ left: rect.left,
378
+ };
148
379
  }
149
- return value.replace(/\s+/g, " ").trim().slice(0, maxLen);
380
+ if (attr === "styles") {
381
+ if (!styleProps.length) {
382
+ return {};
383
+ }
384
+ const computed = window.getComputedStyle(el);
385
+ const selected = {};
386
+ for (const prop of styleProps) {
387
+ selected[prop] = computed.getPropertyValue(prop);
388
+ }
389
+ return selected;
390
+ }
391
+ if (attr === "visible") {
392
+ const computed = window.getComputedStyle(el);
393
+ const rect = el.getBoundingClientRect();
394
+ const styleVisible = computed.display !== "none" && computed.visibility !== "hidden" && computed.opacity !== "0";
395
+ const hasRenderedBox = rect.width > 0 || rect.height > 0;
396
+ return styleVisible && hasRenderedBox;
397
+ }
398
+ if (attr === "enabled") {
399
+ const candidate = el;
400
+ return candidate.disabled !== true && el.getAttribute("aria-disabled") !== "true";
401
+ }
402
+ if (attr === "checked") {
403
+ if (el instanceof HTMLInputElement) {
404
+ return el.checked;
405
+ }
406
+ return el.getAttribute("aria-checked") === "true";
407
+ }
408
+ return normalizeStringValue(el.getAttribute(attr) || "", stringCap);
150
409
  };
410
+ if (attr === "box") {
411
+ values[name] = getValue(filtered[0]);
412
+ continue;
413
+ }
151
414
  if (all) {
152
- values[name] = filtered.map(getValue).filter((val) => val.length > 0);
415
+ values[name] = filtered.map(getValue).filter((val) => typeof val !== "string" || val.length > 0);
153
416
  }
154
417
  else {
155
418
  values[name] = getValue(filtered[0]);
@@ -155,7 +155,15 @@ async function captureVisible(windowId, format, quality) {
155
155
  if (format === "jpeg") {
156
156
  options.quality = quality;
157
157
  }
158
- return chrome.tabs.captureVisibleTab(windowId, options);
158
+ // Retry once after a short delay — headless Chrome on Windows can fail the
159
+ // first readback when the compositor hasn't fully initialised.
160
+ try {
161
+ return await chrome.tabs.captureVisibleTab(windowId, options);
162
+ }
163
+ catch {
164
+ await new Promise((r) => setTimeout(r, 500));
165
+ return chrome.tabs.captureVisibleTab(windowId, options);
166
+ }
159
167
  }
160
168
  async function getPageMetrics(tabId, timeoutMs, deps) {
161
169
  const result = await deps.executeWithTimeout(tabId, timeoutMs, () => {
@@ -19,5 +19,8 @@
19
19
  "background": {
20
20
  "service_worker": "background.js"
21
21
  },
22
- "version_name": "0.6.0-alpha.9"
22
+ "action": {
23
+ "default_title": "Tab Control"
24
+ },
25
+ "version_name": "0.6.0-rc.10"
23
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabctl",
3
- "version": "0.6.0-alpha.9",
3
+ "version": "0.6.0-rc.10",
4
4
  "description": "CLI tool to manage and analyze browser tabs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,6 +42,7 @@
42
42
  "test": "npm run build && npm run rust:verify",
43
43
  "test:unit": "npm run rust:test",
44
44
  "test:integration": "cargo test --manifest-path rust/Cargo.toml --test browser_integration --test browser_coverage --test browser_primitives --test local_commands -- --ignored --nocapture --test-threads=1",
45
+ "test:smoke": "node scripts/smoke-test.js",
45
46
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" ",
46
47
  "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true"
47
48
  },
@@ -52,7 +53,7 @@
52
53
  "typescript": "^5.4.5"
53
54
  },
54
55
  "optionalDependencies": {
55
- "tabctl-win32-x64": "0.3.0"
56
+ "tabctl-win32-x64": "0.6.0-rc.10"
56
57
  },
57
58
  "dependencies": {
58
59
  "normalize-url": "^8.1.1"