Scatta coverage

100%

Lines covered by npm test · updated 2026-10-21 10:48 UTC

ran ran, but a branch never did never ran

options.js

100%lines
100%branches
100%functions
16×// Scatta settings page. The popup reads these from chrome.storage.
26×
36×const DEFAULTS = { defaultMode: "visible", autoClose: false, autoCloseDelay: 2 };
46×
56×const defaultModeSelect = document.getElementById("default-mode");
66×const autoCloseToggle = document.getElementById("auto-close");
76×const delaySelect = document.getElementById("auto-close-delay");
86×const delayRow = document.getElementById("delay-row");
96×const savedEl = document.getElementById("saved");
106×
116×// The delay only matters when auto-close is on.
129×function updateDelayState() {
139× delaySelect.disabled = !autoCloseToggle.checked;
149× delayRow.className = autoCloseToggle.checked ? "row" : "row disabled";
159×}
166×
176×chrome.storage.local.get(DEFAULTS).then(({ defaultMode, autoClose, autoCloseDelay }) => {
186× defaultModeSelect.value = defaultMode;
196× autoCloseToggle.checked = autoClose;
206× delaySelect.value = String(autoCloseDelay);
216× updateDelayState();
226×});
236×
243×async function save() {
253× updateDelayState();
263× await chrome.storage.local.set({
273× defaultMode: defaultModeSelect.value,
283× autoClose: autoCloseToggle.checked,
293× autoCloseDelay: Number(delaySelect.value)
303× });
313× savedEl.textContent = "Saved.";
323×}
336×
346×defaultModeSelect.addEventListener("change", save);
356×autoCloseToggle.addEventListener("change", save);
366×delaySelect.addEventListener("change", save);
37

popup.js

100%lines
97.5%branches
96.9%functions
144×// Scatta: copies a screenshot of the current tab to the clipboard.
244×
344×const fullBtn = document.getElementById("full");
444×const visibleBtn = document.getElementById("visible");
544×const statusEl = document.getElementById("status");
644×const saveToggle = document.getElementById("save-file");
744×const toggleHint = document.getElementById("toggle-hint");
844×const settingsLink = document.getElementById("settings");
944×
1044×// Pages taller than this (in image pixels) get a gentle warning,
1144×// because big images are shrunk when pasted into a chat.
1244×const TALL_WARNING_PX = 5000;
1344×
1444×// Set on the settings page (options.html).
1544×const settings = { autoClose: false, autoCloseDelay: 2 };
1644×let closeTimer = null;
1744×
1845×function updateLabels() {
1945× const save = saveToggle.checked;
2045× document.querySelectorAll(".action").forEach((el) => {
2190× el.textContent = save ? "Save" : "Copy";
2245× });
2345× toggleHint.textContent = save ? "On: saves a PNG to Downloads" : "Off: copies to the clipboard";
2445×}
2544×
2644×// Makes full page the first, highlighted button, so Enter takes it.
271×function useFullPageAsDefault() {
281× visibleBtn.before(fullBtn);
291× fullBtn.className = "btn primary";
301× visibleBtn.className = "btn";
311× fullBtn.focus();
321×}
3344×
3444×chrome.storage.local.get({ saveFile: false, defaultMode: "visible", ...settings }).then(({ saveFile, defaultMode, autoClose, autoCloseDelay }) => {
3544× saveToggle.checked = saveFile;
3644× if (defaultMode === "full") useFullPageAsDefault();
3744× settings.autoClose = autoClose;
3844× settings.autoCloseDelay = autoCloseDelay;
3944× updateLabels();
4044×});
4144×
4244×settingsLink.addEventListener("click", (e) => {
431× e.preventDefault();
441× chrome.runtime.openOptionsPage();
4544×});
4644×
4744×// Any click or key press means the user wants to do something else,
4844×// so don't close the popup on them.
4926×function cancelClose() {
5026× clearTimeout(closeTimer);
5126× closeTimer = null;
5226×}
5344×document.addEventListener("pointerdown", cancelClose);
5444×document.addEventListener("keydown", cancelClose);
5544×
5644×saveToggle.addEventListener("change", () => {
571× chrome.storage.local.set({ saveFile: saveToggle.checked });
581× updateLabels();
591× setStatus("");
6044×});
6144×
6242×function setStatus(text, kind = "") {
6342× statusEl.textContent = text;
6442× statusEl.className = kind;
6542×}
6644×
6740×function setBusy(busy) {
6840× fullBtn.disabled = busy;
6940× visibleBtn.disabled = busy;
7040×}
7144×
7220×async function getActiveTab() {
7320× const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
7420× if (!tab) throw new Error("No active tab found.");
7519× return tab;
7620×}
7744×
7818×function base64ToPngBlob(base64) {
7918× const bytes = atob(base64);
8018× const arr = new Uint8Array(bytes.length);
8118× for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
8218× return new Blob([arr], { type: "image/png" });
8318×}
8444×
8513×async function copyBlob(blob) {
8613× await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
8713×}
8844×
897×function makeFileName(tab) {
907× let host = "page";
917× try { host = new URL(tab.url).hostname.replace(/^www\./, ""); } catch (_) {}
927× const d = new Date();
937× const pad = (n) => String(n).padStart(2, "0");
947× const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
957× return `scatta/${host}_${stamp}.png`;
967×}
9744×
984×async function saveBlob(blob, tab) {
994× const url = URL.createObjectURL(blob);
1004× try {
1014× return await chrome.downloads.download({ url, filename: makeFileName(tab), saveAs: false });
1024× } finally {
1034× // Give Chrome time to read the file before freeing it.
1044× setTimeout(() => URL.revokeObjectURL(url), 60000);
1054× }
1064×}
10744×
10844×// Resolves with the download's final state ("complete" or "interrupted").
10944×// Closing the popup frees the image, so a big file must finish first.
1102×function waitForDownload(id) {
1112× return new Promise((resolve) => {
1122× const finish = (state) => {
1132× chrome.downloads.onChanged.removeListener(onChanged);
1142× resolve(state);
1152× };
1162× const onChanged = (delta) => {
1171× if (delta.id === id && delta.state && delta.state.current !== "in_progress") {
1181× finish(delta.state.current);
1191× }
1202× };
1212× chrome.downloads.onChanged.addListener(onChanged);
1222× // It may already be done before the listener was added.
1232× chrome.downloads.search({ id }).then(([item]) => {
1242× if (item && item.state !== "in_progress") finish(item.state);
1252× });
1262× });
1272×}
12844×
1295×async function closeWhenDone(downloadId) {
1305× if (downloadId !== undefined) {
1312× const state = await waitForDownload(downloadId);
1322× if (state !== "complete") {
1331× setStatus(friendlyError("Download interrupted"), "error");
1341× return;
1351× }
1362× }
1374× cancelClose();
1384× closeTimer = setTimeout(() => window.close(), settings.autoCloseDelay * 1000);
1395×}
14044×
14114×async function describeSize(blob) {
14214× const img = await createImageBitmap(blob);
14314× const size = { width: img.width, height: img.height };
14414× img.close();
14514× return size;
14614×}
14744×
14844×// Runs in the page. Works out how tall the page would be if nothing
14944×// scrolled inside its own box (like an app with a fixed sidebar).
15044×const MEASURE_SCRIPT = `(() => {
15144× const vh = window.innerHeight;
15244× const vw = window.innerWidth;
15344× let extra = 0;
15444× for (const el of document.querySelectorAll("*")) {
15544× if (el === document.documentElement || el === document.body) continue;
15644× const style = getComputedStyle(el);
15744× if (!/(auto|scroll)/.test(style.overflowY)) continue;
15844× const hidden = el.scrollHeight - el.clientHeight;
15944× if (hidden <= 0) continue;
16044× // Only count boxes that are a real part of the layout, not small widgets.
16144× if (el.clientHeight < vh * 0.4) continue;
16244× extra = Math.max(extra, hidden);
16344× }
16444× const docHeight = Math.max(
16544× document.documentElement.scrollHeight,
16644× document.body ? document.body.scrollHeight : 0
16744× );
16844× return JSON.stringify({ width: vw, height: Math.max(docHeight, vh + extra) });
16944×})()`;
17044×
17144×const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17244×
17312×async function measurePage(target) {
17412× const res = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
17512× expression: MEASURE_SCRIPT,
17612× returnByValue: true
17712× });
17812× return JSON.parse(res.result.value);
17912×}
18044×
18144×// Full page: makes the browser window "pretend" to be as tall as the
18244×// whole page, takes the screenshot, then puts the window back.
1837×async function captureFullPage(tab) {
1847× const target = { tabId: tab.id };
1857× await chrome.debugger.attach(target, "1.3");
1866× let overridden = false;
1876× try {
1886× let { width, height } = await measurePage(target);
1896×
1906× await chrome.debugger.sendCommand(target, "Emulation.setDeviceMetricsOverride", {
1916× width, height, deviceScaleFactor: 0, mobile: false
1926× });
1936× overridden = true;
1946× await sleep(400); // let the page lay itself out at the new size
1956×
1966× // Measure again: some pages grow once they have more room.
1976× const after = await measurePage(target);
1987× if (after.height > height) {
1991× height = after.height;
2001× await chrome.debugger.sendCommand(target, "Emulation.setDeviceMetricsOverride", {
2011× width, height, deviceScaleFactor: 0, mobile: false
2021× });
2031× await sleep(400);
2041× }
2056×
2066× const result = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", {
2076× format: "png",
2086× captureBeyondViewport: true,
2096× clip: { x: 0, y: 0, width, height, scale: 1 }
2106× });
2116× return base64ToPngBlob(result.data);
2126× } finally {
2136× if (overridden) {
2146× try { await chrome.debugger.sendCommand(target, "Emulation.clearDeviceMetricsOverride"); } catch (_) {}
2156× }
2166× // Always detach, so the yellow "debugging" bar goes away.
2176× try { await chrome.debugger.detach(target); } catch (_) { /* already detached */ }
2186× }
2197×}
22044×
22144×// Visible area: a normal screenshot of what is on screen.
22212×async function captureVisible(tab) {
22312× const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
22411× return base64ToPngBlob(dataUrl.split(",")[1]);
22512×}
22644×
22714×function friendlyError(err) {
22814× const msg = String(err && err.message ? err.message : err);
22914× if (/chrome:\/\/|cannot access|Cannot attach|chrome-extension/i.test(msg)) {
2303× return "Chrome doesn't allow screenshots of this page (for example chrome:// pages or the Web Store).";
2313× }
23214× if (/Another debugger/i.test(msg)) {
2332× return "DevTools or another tool is already debugging this tab. Close DevTools and try again.";
2342× }
23514× if (/download/i.test(msg)) {
2363× return "Couldn't save the file. Check Chrome's download settings and try again.";
2373× }
23814× if (/Document is not focused|clipboard/i.test(msg)) {
2393× return "Couldn't copy to the clipboard. Click the Scatta icon again and retry.";
2403× }
2413× return "Something went wrong: " + msg;
24214×}
24344×
24420×async function run(mode) {
24520× cancelClose();
24620× setBusy(true);
24720× setStatus(mode === "full" ? "Capturing the full page..." : "Capturing the visible area...");
24820× let closeAfter = false;
24920× let downloadId;
25020× try {
25120× const tab = await getActiveTab();
25220× const blob = mode === "full" ? await captureFullPage(tab) : await captureVisible(tab);
25311×
25411× const saving = saveToggle.checked;
25520× if (saving) {
2564× downloadId = await saveBlob(blob, tab);
25720× } else {
25813× // Bring focus back to the popup before writing to the clipboard.
25913× window.focus();
26013× await copyBlob(blob);
26111× }
26214×
26314× const { width, height } = await describeSize(blob);
26414× const done = saving
26520× ? `Saved to Downloads/scatta (${width} × ${height} px).`
26620× : `Copied (${width} × ${height} px). Ready to paste.`;
26720× if (mode === "full" && height > TALL_WARNING_PX) {
2682× setStatus(
2692× `${done} This is a long page, so small text may be hard to read in a chat. Try the visible area for detail.`,
2702× "warn"
2712× );
27220× } else {
27312× setStatus(done, "ok");
27412× // Only close on a plain success: warnings and errors need reading.
27512× closeAfter = settings.autoClose;
27612× }
27720× } catch (err) {
2786× console.error(err);
2796× setStatus(friendlyError(err), "error");
28020× } finally {
28120× setBusy(false);
28220× }
28320× if (closeAfter) await closeWhenDone(downloadId);
28420×}
28544×
28644×fullBtn.addEventListener("click", () => run("full"));
28744×visibleBtn.addEventListener("click", () => run("visible"));
288