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.
- package/README.md +97 -76
- package/dist/extension/background.js +1465 -474
- package/dist/extension/lib/content.js +293 -30
- package/dist/extension/lib/screenshot.js +9 -1
- package/dist/extension/manifest.json +4 -1
- package/package.json +3 -2
|
@@ -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
|
-
|
|
340
|
+
return normalizeStringValue(el.textContent || "", stringCap);
|
|
341
|
+
}
|
|
342
|
+
if (attr === "html") {
|
|
343
|
+
return normalizeStringValue(el.outerHTML || "", htmlCap);
|
|
125
344
|
}
|
|
126
|
-
|
|
345
|
+
if (attr === "href-url" || attr === "src-url") {
|
|
127
346
|
const rawValue = el.getAttribute(attr === "href-url" ? "href" : "src") || "";
|
|
128
347
|
if (!rawValue) {
|
|
129
|
-
|
|
348
|
+
return "";
|
|
130
349
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabctl",
|
|
3
|
-
"version": "0.6.0-
|
|
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.
|
|
56
|
+
"tabctl-win32-x64": "0.6.0-rc.10"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|
|
58
59
|
"normalize-url": "^8.1.1"
|