mirror of
https://github.com/haperone/local-image-compress.git
synced 2026-07-22 16:40:24 +00:00
1535 lines
75 KiB
JavaScript
1535 lines
75 KiB
JavaScript
(async () => {
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const electron = require("electron");
|
|
|
|
const pluginId = "local-image-compress";
|
|
const startedAt = new Date().toISOString();
|
|
const runStamp = startedAt.replace(/[:.]/g, "-").replace(/Z$/, "");
|
|
const qaStateMarker = "QA-LIC-Runtime-";
|
|
const qaRoot = `${qaStateMarker}${runStamp}`;
|
|
const staleQaArtifactParents = ["", "Compressed", "files", "files/Compressed"];
|
|
const report = {
|
|
startedAt,
|
|
pluginId,
|
|
qaRoot,
|
|
checks: [],
|
|
failures: [],
|
|
warnings: [],
|
|
metrics: {}
|
|
};
|
|
|
|
const restoreStack = [];
|
|
const cleanupStack = [];
|
|
let progressPath = "";
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
const normalizeVaultPath = (value) => String(value || "").replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/+|\/+$/g, "");
|
|
const joinVault = (...parts) => normalizeVaultPath(parts.filter(Boolean).join("/"));
|
|
const serializeError = (error) => ({
|
|
name: error?.name || "Error",
|
|
message: error?.message || String(error),
|
|
stack: error?.stack ? String(error.stack).split("\n").slice(0, 6).join("\n") : ""
|
|
});
|
|
const assert = (condition, message, details) => {
|
|
if (!condition) {
|
|
const error = new Error(message);
|
|
if (details !== undefined) {
|
|
error.details = details;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
const recordWarning = (name, details) => report.warnings.push({ name, details });
|
|
const writeProgress = (status, name, details = {}) => {
|
|
if (!progressPath) {
|
|
return;
|
|
}
|
|
fs.writeFileSync(progressPath, JSON.stringify({
|
|
status,
|
|
name,
|
|
updatedAt: new Date().toISOString(),
|
|
...details
|
|
}, null, 2));
|
|
};
|
|
const check = async (name, fn) => {
|
|
const start = Date.now();
|
|
writeProgress("running", name);
|
|
try {
|
|
const details = await fn();
|
|
report.checks.push({
|
|
name,
|
|
status: "pass",
|
|
durationMs: Date.now() - start,
|
|
...(details === undefined ? {} : { details })
|
|
});
|
|
writeProgress("passed", name, { durationMs: Date.now() - start });
|
|
return details;
|
|
} catch (error) {
|
|
const failure = {
|
|
name,
|
|
status: "fail",
|
|
durationMs: Date.now() - start,
|
|
error: serializeError(error),
|
|
...(error?.details === undefined ? {} : { details: error.details })
|
|
};
|
|
report.checks.push(failure);
|
|
report.failures.push(failure);
|
|
writeProgress("failed", name, { durationMs: Date.now() - start, error: serializeError(error) });
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const p = app?.plugins?.plugins?.[pluginId];
|
|
if (!p) {
|
|
throw new Error(`${pluginId} is not loaded`);
|
|
}
|
|
progressPath = path.join(p.getPluginDirectory(), "qa-backups", "runtime-qa-progress.json");
|
|
fs.mkdirSync(path.dirname(progressPath), { recursive: true });
|
|
writeProgress("starting", "runtime QA");
|
|
|
|
const vaultBase = p.moveService?.getVaultBasePath?.() || app.vault.adapter?.getBasePath?.() || app.vault.adapter?.basePath;
|
|
if (!vaultBase) {
|
|
throw new Error("Vault base path is unavailable");
|
|
}
|
|
const absolute = (vaultRel) => path.join(vaultBase, ...normalizeVaultPath(vaultRel).split("/").filter(Boolean));
|
|
const outputRelFor = (sourceRel) => joinVault(p.getOutputFolder(), sourceRel);
|
|
const outputAbsFor = (sourceRel) => absolute(outputRelFor(sourceRel));
|
|
const existsRel = async (vaultRel) => fs.promises.access(absolute(vaultRel)).then(() => true).catch(() => false);
|
|
const statRel = async (vaultRel) => fs.promises.stat(absolute(vaultRel));
|
|
const safeRmAbs = async (targetAbs) => {
|
|
const resolvedBase = path.resolve(vaultBase);
|
|
const resolvedTarget = path.resolve(targetAbs);
|
|
const relative = path.relative(resolvedBase, resolvedTarget);
|
|
assert(relative && !relative.startsWith("..") && !path.isAbsolute(relative), "Refusing to remove outside vault", { targetAbs, vaultBase });
|
|
await fs.promises.rm(resolvedTarget, { recursive: true, force: true });
|
|
};
|
|
const removeQaRunRootsUnder = async (parentRel) => {
|
|
const parentAbs = parentRel ? absolute(parentRel) : vaultBase;
|
|
let entries = [];
|
|
try {
|
|
entries = await fs.promises.readdir(parentAbs, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") {
|
|
recordWarning("cleanup.read-qa-artifact-parent", { parentRel, error: serializeError(error) });
|
|
}
|
|
return;
|
|
}
|
|
await Promise.all(entries
|
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith(qaStateMarker))
|
|
.map((entry) => safeRmAbs(path.join(parentAbs, entry.name))));
|
|
};
|
|
const removeRuntimeQaVaultArtifacts = async () => {
|
|
await safeRmAbs(absolute(qaRoot));
|
|
await Promise.all(staleQaArtifactParents.map((parentRel) => removeQaRunRootsUnder(parentRel)));
|
|
};
|
|
const isQaOwnedVaultPath = (vaultRel) => {
|
|
const normalized = normalizeVaultPath(vaultRel);
|
|
return normalized === qaRoot || normalized.startsWith(`${qaRoot}/`);
|
|
};
|
|
const getEscapedQaFiles = (files) => Array.from(files || [])
|
|
.filter((file) => file?.path && !isQaOwnedVaultPath(file.path))
|
|
.map((file) => file.path);
|
|
const assertQaOwnedFiles = (files, action) => {
|
|
const escaped = getEscapedQaFiles(files);
|
|
assert(escaped.length === 0, `Runtime QA attempted ${action} outside ${qaRoot}`, {
|
|
escaped: escaped.slice(0, 25),
|
|
escapedCount: escaped.length,
|
|
allowedRoots: p.settings?.allowedRoots,
|
|
outputFolder: p.settings?.outputFolder
|
|
});
|
|
};
|
|
const assertQaRuntimeScope = async (label) => {
|
|
const allowedRoots = Array.isArray(p.settings?.allowedRoots) ? p.settings.allowedRoots.map(normalizeVaultPath) : [];
|
|
assert(allowedRoots.length === 1 && normalizeVaultPathRootForQa(allowedRoots[0]) === qaRoot, `${label}: runtime QA allowedRoots escaped QA root`, {
|
|
allowedRoots: p.settings?.allowedRoots
|
|
});
|
|
assert(normalizeVaultPath(p.getOutputFolder()) === `${qaRoot}/Compressed`, `${label}: runtime QA output folder escaped QA root`, {
|
|
outputFolder: p.getOutputFolder()
|
|
});
|
|
if (p.imageIndex?.isReady?.()) {
|
|
assertQaOwnedFiles(p.getAllImageFiles(), `${label} image-index scope`);
|
|
}
|
|
};
|
|
function normalizeVaultPathRootForQa(vaultRel) {
|
|
return normalizeVaultPath(vaultRel).replace(/^\/+|\/+$/g, "");
|
|
}
|
|
|
|
// --- QA settings safety net (must survive a hard-killed run) ---
|
|
// This harness overwrites the user's real data.json. The in-renderer restore in the finally
|
|
// below only runs if execution reaches it; a hard process kill skips it and leaves QA values
|
|
// behind. So: auto-heal data.json if a previous crashed run left QA state in it, then snapshot
|
|
// the clean settings to disk *before* any mutation so recovery is always possible.
|
|
const preQaSettingsBackupPath = path.join(p.getPluginDirectory(), "qa-backups", "pre-qa-data-backup.json");
|
|
const settingsLookLikeQaState = (settings) => {
|
|
const outputFolder = String(settings?.outputFolder || "");
|
|
const roots = Array.isArray(settings?.allowedRoots) ? settings.allowedRoots : [];
|
|
return outputFolder.includes(qaStateMarker) || roots.some((root) => String(root).includes(qaStateMarker));
|
|
};
|
|
if (settingsLookLikeQaState(p.settings)) {
|
|
try {
|
|
p.settings = JSON.parse(await fs.promises.readFile(preQaSettingsBackupPath, "utf8"));
|
|
await p.saveSettings();
|
|
recordWarning("qa.autoHealedPollutedSettings", { from: preQaSettingsBackupPath });
|
|
} catch (error) {
|
|
recordWarning("qa.autoHealUnavailable", serializeError(error));
|
|
}
|
|
}
|
|
if (!settingsLookLikeQaState(p.settings)) {
|
|
try {
|
|
await fs.promises.mkdir(path.dirname(preQaSettingsBackupPath), { recursive: true });
|
|
await fs.promises.writeFile(preQaSettingsBackupPath, JSON.stringify(p.settings, null, 2));
|
|
} catch (error) {
|
|
recordWarning("qa.preBackupFailed", serializeError(error));
|
|
}
|
|
}
|
|
// --- end QA settings safety net ---
|
|
|
|
const originalSettings = clone(p.settings);
|
|
const originalCacheData = clone(p.cache.cacheData);
|
|
const originalOpenPath = electron.shell.openPath;
|
|
const originalNewFileDelay = p.newFileQueue?.AUTO_COMPRESS_DELAY;
|
|
const originalCompressFile = p.compressFile;
|
|
const originalRunCompressionBatch = p.runCompressionBatch;
|
|
if (typeof originalCompressFile === "function") {
|
|
p.compressFile = async (file, ...args) => {
|
|
assertQaOwnedFiles([file], "compressFile");
|
|
return await originalCompressFile.call(p, file, ...args);
|
|
};
|
|
restoreStack.push(async () => {
|
|
p.compressFile = originalCompressFile;
|
|
});
|
|
}
|
|
if (typeof originalRunCompressionBatch === "function") {
|
|
p.runCompressionBatch = async (files, ...args) => {
|
|
assertQaOwnedFiles(files, "runCompressionBatch");
|
|
return await originalRunCompressionBatch.call(p, files, ...args);
|
|
};
|
|
restoreStack.push(async () => {
|
|
p.runCompressionBatch = originalRunCompressionBatch;
|
|
});
|
|
}
|
|
const originalGetBackupStoragePaths = p.getBackupStoragePaths;
|
|
if (typeof originalGetBackupStoragePaths === "function") {
|
|
const qaBackupStorageRoot = absolute(joinVault(qaRoot, "IsolatedBackupStorage"));
|
|
p.getBackupStoragePaths = () => {
|
|
const originalPaths = originalGetBackupStoragePaths.call(p);
|
|
return {
|
|
...originalPaths,
|
|
root: qaBackupStorageRoot,
|
|
backupsRoot: path.join(qaBackupStorageRoot, "backups"),
|
|
originalFilesBackups: path.join(qaBackupStorageRoot, "backups", "originals")
|
|
};
|
|
};
|
|
restoreStack.push(async () => {
|
|
p.getBackupStoragePaths = originalGetBackupStoragePaths;
|
|
});
|
|
}
|
|
restoreStack.push(async () => {
|
|
try {
|
|
p.statusBarController?.closeMenu?.();
|
|
p.closeManagedModals?.();
|
|
} catch (error) {
|
|
recordWarning("cleanup.close-ui", serializeError(error));
|
|
}
|
|
if (originalNewFileDelay !== undefined && p.newFileQueue) {
|
|
p.newFileQueue.AUTO_COMPRESS_DELAY = originalNewFileDelay;
|
|
}
|
|
electron.shell.openPath = originalOpenPath;
|
|
p.settings = clone(originalSettings);
|
|
await p.saveSettings();
|
|
p.cache.cacheData = clone(originalCacheData);
|
|
await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
|
|
await p.rebuildImageIndex?.("runtime-qa-restore");
|
|
await p.statusBarController?.update?.();
|
|
});
|
|
cleanupStack.push(async () => {
|
|
await removeRuntimeQaVaultArtifacts();
|
|
});
|
|
|
|
function patchMethod(target, name, replacement) {
|
|
const original = target?.[name];
|
|
target[name] = replacement;
|
|
restoreStack.push(async () => {
|
|
target[name] = original;
|
|
});
|
|
return original;
|
|
}
|
|
|
|
async function ensureFolder(vaultRel) {
|
|
const normalized = normalizeVaultPath(vaultRel);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
const parts = normalized.split("/");
|
|
let current = "";
|
|
for (const part of parts) {
|
|
current = current ? `${current}/${part}` : part;
|
|
if (!app.vault.getAbstractFileByPath(current)) {
|
|
await app.vault.createFolder(current);
|
|
await sleep(20);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function createTextFile(vaultRel, text) {
|
|
await ensureFolder(path.posix.dirname(normalizeVaultPath(vaultRel)));
|
|
return await app.vault.create(vaultRel, text);
|
|
}
|
|
|
|
function drawPattern(ctx, width, height, variant) {
|
|
const gradient = ctx.createLinearGradient(0, 0, width, height);
|
|
gradient.addColorStop(0, variant % 2 ? "#fb7185" : "#0ea5e9");
|
|
gradient.addColorStop(0.45, variant % 3 ? "#f8fafc" : "#22c55e");
|
|
gradient.addColorStop(1, variant % 2 ? "#0f172a" : "#f59e0b");
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, width, height);
|
|
for (let y = 0; y < height; y += 18) {
|
|
for (let x = 0; x < width; x += 18) {
|
|
const r = (x * 13 + y * 3 + variant * 19) % 255;
|
|
const g = (x * 7 + y * 17 + variant * 11) % 255;
|
|
const b = (x * 5 + y * 23 + variant * 29) % 255;
|
|
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.55)`;
|
|
ctx.fillRect(x, y, 12 + (variant % 5), 12 + ((x + y) % 5));
|
|
}
|
|
}
|
|
ctx.fillStyle = "rgba(255,255,255,0.32)";
|
|
ctx.font = "bold 96px sans-serif";
|
|
ctx.fillText(`QA ${variant}`, 44, Math.floor(height * 0.58));
|
|
}
|
|
|
|
async function makeImageBuffer(mime, width, height, quality, variant) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
drawPattern(ctx, width, height, variant);
|
|
const blob = await new Promise((resolve, reject) => {
|
|
canvas.toBlob((value) => value ? resolve(value) : reject(new Error(`canvas.toBlob failed for ${mime}`)), mime, quality);
|
|
});
|
|
return await blob.arrayBuffer();
|
|
}
|
|
|
|
async function createImage(vaultRel, kind, variant) {
|
|
const normalized = normalizeVaultPath(vaultRel);
|
|
await ensureFolder(path.posix.dirname(normalized));
|
|
const buffer = kind === "png"
|
|
? await makeImageBuffer("image/png", 760, 520, undefined, variant)
|
|
: await makeImageBuffer("image/jpeg", 960, 640, 0.99, variant);
|
|
const file = await app.vault.createBinary(normalized, buffer);
|
|
await sleep(80);
|
|
return file;
|
|
}
|
|
|
|
async function createSmallJpeg(vaultRel) {
|
|
const normalized = normalizeVaultPath(vaultRel);
|
|
await ensureFolder(path.posix.dirname(normalized));
|
|
const buffer = await makeImageBuffer("image/jpeg", 64, 64, 0.45, 999);
|
|
const file = await app.vault.createBinary(normalized, buffer);
|
|
await sleep(80);
|
|
return file;
|
|
}
|
|
|
|
async function waitForFile(vaultRel, timeoutMs = 90000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await existsRel(vaultRel)) {
|
|
return true;
|
|
}
|
|
await sleep(250);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function waitForCompressionIdle(timeoutMs = 90000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if ((p.compressionWorkflowsInFlight || 0) === 0 && !p.isAutoMoveRunning && !p.moveService?.moveOperationInProgress && !p.backgroundCompressionService?.isBackgroundCompressionRunning) {
|
|
return true;
|
|
}
|
|
await sleep(250);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function waitForFreshCacheEntry(file, timeoutMs = 5000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const freshEntry = await p.cache.getFreshEntryForFile(file);
|
|
if (freshEntry) {
|
|
return freshEntry;
|
|
}
|
|
await sleep(150);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getStoredCacheEntryWithState(filePath, state) {
|
|
const entries = p.cache.getEntriesForPath(filePath) || [];
|
|
const matchingEntries = entries
|
|
.map(([cacheKey, entry]) => ({ cacheKey, entry }))
|
|
.filter(({ entry }) => entry?.state === state)
|
|
.sort((left, right) => Number(right.entry?.timestamp || 0) - Number(left.entry?.timestamp || 0));
|
|
return matchingEntries[0] || null;
|
|
}
|
|
|
|
async function waitForStatusMenu(timeoutMs = 5000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const menu = document.querySelector(".tiny-local-status-menu");
|
|
if (menu) {
|
|
return menu;
|
|
}
|
|
await sleep(100);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function assertCompressed(file, label) {
|
|
await waitForCompressionIdle();
|
|
const outRel = outputRelFor(file.path);
|
|
assert(await waitForFile(outRel), `${label}: compressed output was not created`, { source: file.path, outRel });
|
|
const originalStats = await statRel(file.path);
|
|
const compressedStats = await statRel(outRel);
|
|
assert(compressedStats.size > 0, `${label}: compressed output is empty`, { outRel });
|
|
assert(compressedStats.size < originalStats.size, `${label}: compressed output is not smaller`, {
|
|
source: file.path,
|
|
originalSize: originalStats.size,
|
|
compressedSize: compressedStats.size
|
|
});
|
|
const freshEntry = await waitForFreshCacheEntry(file);
|
|
assert(!!freshEntry, `${label}: cache entry missing`, { source: file.path });
|
|
assert(freshEntry.entry?.state === "pending_move", `${label}: cache entry is not pending_move`, freshEntry.entry);
|
|
return {
|
|
source: file.path,
|
|
output: outRel,
|
|
originalSize: originalStats.size,
|
|
compressedSize: compressedStats.size,
|
|
savedBytes: originalStats.size - compressedStats.size
|
|
};
|
|
}
|
|
|
|
function dispatchInput(input, value) {
|
|
input.value = String(value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
|
|
function clickElement(element) {
|
|
if (typeof element.click === "function") {
|
|
element.click();
|
|
return;
|
|
}
|
|
element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
}
|
|
|
|
function isToggleOn(toggle) {
|
|
if (toggle.classList.contains("is-enabled")) {
|
|
return true;
|
|
}
|
|
const input = toggle.querySelector?.("input[type='checkbox']");
|
|
if (input) {
|
|
return !!input.checked;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getToggleLabel(toggle) {
|
|
return toggle.closest?.(".setting-item")?.querySelector?.(".setting-item-name")?.textContent?.trim() || "";
|
|
}
|
|
|
|
function refindToggleByLabel(label) {
|
|
if (!label) {
|
|
return null;
|
|
}
|
|
const root = app.setting?.activeTab?.containerEl;
|
|
if (!root) {
|
|
return null;
|
|
}
|
|
return Array.from(root.querySelectorAll(".checkbox-container")).find((candidate) => getToggleLabel(candidate) === label) || null;
|
|
}
|
|
|
|
async function setToggle(toggle, value) {
|
|
const label = getToggleLabel(toggle);
|
|
let current = toggle;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
if (!current?.isConnected) {
|
|
current = refindToggleByLabel(label) || current;
|
|
}
|
|
if (isToggleOn(current) === value) {
|
|
break;
|
|
}
|
|
clickElement(current);
|
|
await sleep(700);
|
|
}
|
|
if (isToggleOn(current) !== value) {
|
|
const input = current.querySelector?.("input[type='checkbox']");
|
|
if (input) {
|
|
input.checked = value;
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
await sleep(700);
|
|
}
|
|
}
|
|
if (isToggleOn(current) !== value) {
|
|
recordWarning("settings.toggleVisualState", {
|
|
label,
|
|
requested: value,
|
|
actual: isToggleOn(current)
|
|
});
|
|
}
|
|
}
|
|
|
|
async function openSettings() {
|
|
app.setting.open();
|
|
let tab = null;
|
|
const deadline = Date.now() + 5000;
|
|
do {
|
|
app.setting.openTabById(pluginId);
|
|
await sleep(250);
|
|
tab = app.setting.activeTab;
|
|
if (tab?.id === pluginId) {
|
|
break;
|
|
}
|
|
} while (Date.now() < deadline);
|
|
assert(tab?.id === pluginId, "Plugin settings tab is not active", { activeId: tab?.id });
|
|
if (typeof tab.renderSettings === "function") {
|
|
await tab.renderSettings();
|
|
} else if (typeof tab.display === "function") {
|
|
tab.display();
|
|
}
|
|
await sleep(600);
|
|
return tab.containerEl;
|
|
}
|
|
|
|
async function closeTopModal() {
|
|
const doc = app.workspace?.activeDocument || document;
|
|
const closeButton = doc.querySelector(".modal-container .modal-close-button");
|
|
if (closeButton) {
|
|
clickElement(closeButton);
|
|
await sleep(200);
|
|
return;
|
|
}
|
|
doc.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
await sleep(200);
|
|
}
|
|
|
|
async function runCommand(commandId, timeoutMs = 90000) {
|
|
await assertQaRuntimeScope(`before command ${commandId}`);
|
|
const result = app.commands.executeCommandById(`${pluginId}:${commandId}`);
|
|
if (result && typeof result.then === "function") {
|
|
await result;
|
|
}
|
|
await waitForCompressionIdle(timeoutMs);
|
|
}
|
|
|
|
async function setupIsolatedState() {
|
|
await removeRuntimeQaVaultArtifacts();
|
|
await ensureFolder(qaRoot);
|
|
p.statusBarController?.closeMenu?.();
|
|
p.closeManagedModals?.();
|
|
p.settings = {
|
|
...clone(p.settings),
|
|
pngQuality: { min: 45, max: 60 },
|
|
jpegQuality: 50,
|
|
allowedRoots: [`${qaRoot}/`],
|
|
outputFolder: `${qaRoot}/Compressed`,
|
|
autoCompressNewFiles: false,
|
|
autoBackgroundCompression: false,
|
|
autoBackgroundThreshold: 10,
|
|
inactivityThresholdMinutes: 1,
|
|
autoBackupsRetentionEnabled: false,
|
|
autoBackupsRetentionDays: 7,
|
|
autoMoveCompressedEnabled: false,
|
|
autoMoveCompressedThreshold: 1
|
|
};
|
|
await p.saveSettings();
|
|
await p.cache.clearCache();
|
|
await p.rebuildImageIndex("runtime-qa-start");
|
|
await assertQaRuntimeScope("setupIsolatedState");
|
|
await p.statusBarController.update();
|
|
if (p.newFileQueue) {
|
|
p.newFileQueue.AUTO_COMPRESS_DELAY = 25;
|
|
}
|
|
}
|
|
|
|
async function restoreQaDefaults() {
|
|
p.settings = {
|
|
...clone(p.settings),
|
|
pngQuality: { min: 45, max: 60 },
|
|
jpegQuality: 50,
|
|
allowedRoots: [`${qaRoot}/`],
|
|
outputFolder: `${qaRoot}/Compressed`,
|
|
autoCompressNewFiles: false,
|
|
autoBackgroundCompression: false,
|
|
autoBackgroundThreshold: 10,
|
|
inactivityThresholdMinutes: 1,
|
|
autoBackupsRetentionEnabled: false,
|
|
autoBackupsRetentionDays: 7,
|
|
autoMoveCompressedEnabled: false,
|
|
autoMoveCompressedThreshold: 1
|
|
};
|
|
await p.saveSettings();
|
|
await assertQaRuntimeScope("restoreQaDefaults");
|
|
}
|
|
|
|
try {
|
|
await setupIsolatedState();
|
|
|
|
await check("runtime: plugin services and commands are loaded", async () => {
|
|
const commandIds = Object.keys(app.commands.commands).filter((id) => id.startsWith(`${pluginId}:`)).sort();
|
|
const expected = [
|
|
`${pluginId}:compress-all-images`,
|
|
`${pluginId}:compress-images-in-folder`,
|
|
`${pluginId}:compress-images-in-note`,
|
|
`${pluginId}:move-compressed-to-files`
|
|
].sort();
|
|
for (const id of expected) {
|
|
assert(commandIds.includes(id), `Missing command ${id}`, { commandIds });
|
|
}
|
|
assert(!!p.cache && !!p.compressor && !!p.moveService && !!p.statusBarController && !!p.imageScanner, "Core services missing");
|
|
assert(p.imageIndex?.isReady?.() === true, "Image index is not ready");
|
|
return { commandIds };
|
|
});
|
|
|
|
await check("runtime: codec readiness and settings application", async () => {
|
|
const binaries = p.compressor.checkBinaries();
|
|
assert(binaries.pngquant === true, "pngquant WASM is not ready", binaries);
|
|
assert(binaries.mozjpeg === true, "mozjpeg WASM is not ready", binaries);
|
|
const pngVersion = await p.getPngCodecVersions?.();
|
|
const jpegVersion = await p.getJpegCodecVersions?.();
|
|
p.applyRuntimeSettings();
|
|
assert(p.pluginGuardService.operationTimeoutMs === 8000, "Internal guard timeout not applied");
|
|
assert(p.compressor.processTimeoutMs === 120000, "Internal compression timeout not applied");
|
|
assert(p.compressor.initTimeoutMs === 60000, "Internal WASM init timeout not applied");
|
|
assert(p.compressor.maxInputBytes === 100 * 1024 * 1024, "Internal input size limit not applied");
|
|
assert(p.compressor.maxImagePixels === 100 * 1000000, "Internal image pixel limit not applied");
|
|
assert(p.backgroundCompressionService.AUTO_BACKGROUND_THRESHOLD === p.settings.autoBackgroundThreshold, "Background threshold not applied");
|
|
return { binaries, pngVersion, jpegVersion };
|
|
});
|
|
|
|
await check("settings: DOM surface has every expected control family", async () => {
|
|
const root = await openSettings();
|
|
assert(p.settingsTab === app.setting.activeTab, "Plugin does not own the active settings tab instance");
|
|
assert(p.settingsTab?._isVisible === true, "Plugin-owned settings tab did not record visible state");
|
|
await p.updateSavingsIndicatorInSettings();
|
|
const labels = Array.from(root.querySelectorAll(".setting-item-name")).map((el) => el.textContent.trim()).filter(Boolean);
|
|
const buttons = Array.from(root.querySelectorAll("button"));
|
|
const textInputs = Array.from(root.querySelectorAll("input[type='text']"));
|
|
const rangeInputs = Array.from(root.querySelectorAll("input[type='range']"));
|
|
const toggles = Array.from(root.querySelectorAll(".checkbox-container"));
|
|
const dropdowns = Array.from(root.querySelectorAll("select"));
|
|
report.metrics.settingsLabels = labels;
|
|
assert(labels.length >= 24, "Settings labels count is too low", { count: labels.length, labels });
|
|
assert(buttons.length >= 8, "Settings buttons count is too low", { count: buttons.length, texts: buttons.map((button) => button.textContent.trim()) });
|
|
assert(textInputs.length >= 2, "Expected PNG and output-folder text inputs", { count: textInputs.length });
|
|
assert(rangeInputs.length >= 5, "Expected all remaining settings sliders", { count: rangeInputs.length });
|
|
assert(toggles.length >= 4, "Expected all settings toggles", { count: toggles.length });
|
|
assert(dropdowns.length >= 0, "Dropdown query failed");
|
|
const savingsTarget = root.querySelector(".tiny-local-savings-tooltip-target");
|
|
if (savingsTarget) {
|
|
assert(savingsTarget.getAttribute("role") === "group", "Savings tooltip target is missing group semantics");
|
|
assert(savingsTarget.getAttribute("tabindex") === "0", "Savings tooltip target is not keyboard focusable");
|
|
assert(!!savingsTarget.getAttribute("aria-label"), "Savings tooltip target is missing an accessible summary");
|
|
savingsTarget.focus();
|
|
await sleep(100);
|
|
const tooltip = document.querySelector(".tiny-local-savings-tooltip");
|
|
assert(tooltip?.getAttribute("role") === "tooltip", "Savings tooltip did not open from keyboard focus");
|
|
savingsTarget.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
|
await sleep(100);
|
|
assert(!document.querySelector(".tiny-local-savings-tooltip"), "Savings tooltip did not close from Escape");
|
|
}
|
|
return {
|
|
labelCount: labels.length,
|
|
buttonCount: buttons.length,
|
|
textInputCount: textInputs.length,
|
|
rangeInputCount: rangeInputs.length,
|
|
toggleCount: toggles.length,
|
|
dropdownCount: dropdowns.length,
|
|
keyboardTooltip: !!savingsTarget
|
|
};
|
|
});
|
|
|
|
await check("localization: selected language reaches commands and settings", async () => {
|
|
const expectedLocale = p.constructor.currentLang;
|
|
const expected = {
|
|
en: { command: "Compress all images in note", setting: "PNG quality (min-max)" },
|
|
ru: { command: "Сжать все изображения в заметке", setting: "Качество PNG (мин-макс)" },
|
|
uk: { command: "Стиснути всі зображення в нотатці", setting: "Якість PNG (мін-макс)" }
|
|
}[expectedLocale];
|
|
assert(expected, "Plugin selected an unsupported built-in language", { expectedLocale });
|
|
const command = app.commands.commands[`${pluginId}:compress-images-in-note`];
|
|
const root = await openSettings();
|
|
const labels = Array.from(root.querySelectorAll(".setting-item-name")).map((el) => el.textContent.trim());
|
|
assert(command?.name?.endsWith(expected.command), "Registered command is not localized", { command: command?.name, expected });
|
|
assert(labels.includes(expected.setting), "Settings DOM is not localized", { expected: expected.setting, labels });
|
|
return { locale: expectedLocale, command: command.name, setting: expected.setting };
|
|
});
|
|
|
|
await check("accessibility: theme variables, motion overrides, and popout ownership", async () => {
|
|
const originalThemeClasses = {
|
|
light: document.body.classList.contains("theme-light"),
|
|
dark: document.body.classList.contains("theme-dark")
|
|
};
|
|
const sample = document.querySelector(".tiny-local-savings-indicator") || document.querySelector(".tiny-local-settings");
|
|
assert(!!sample, "No plugin UI sample available for theme verification");
|
|
const readTheme = (themeClass) => {
|
|
document.body.classList.remove("theme-light", "theme-dark");
|
|
document.body.classList.add(themeClass);
|
|
const style = window.getComputedStyle(sample);
|
|
return {
|
|
color: style.color,
|
|
backgroundColor: style.backgroundColor,
|
|
borderColor: style.borderColor
|
|
};
|
|
};
|
|
let popoutLeaf = null;
|
|
try {
|
|
const light = readTheme("theme-light");
|
|
const dark = readTheme("theme-dark");
|
|
for (const [theme, values] of Object.entries({ light, dark })) {
|
|
assert(Object.values(values).every((value) => value && !value.includes("var(")), `Theme ${theme} left unresolved plugin colors`, values);
|
|
}
|
|
|
|
const mediaRules = [];
|
|
for (const sheet of Array.from(document.styleSheets)) {
|
|
let rules = [];
|
|
try {
|
|
rules = Array.from(sheet.cssRules || []);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const rule of rules) {
|
|
if (typeof rule.conditionText === "string") {
|
|
mediaRules.push({ condition: rule.conditionText, cssText: rule.cssText, rule });
|
|
}
|
|
}
|
|
}
|
|
const reducedMotion = mediaRules.find((rule) => rule.condition.includes("prefers-reduced-motion") && rule.cssText.includes(".tiny-local-"));
|
|
const highContrast = mediaRules.find((rule) => rule.condition.includes("prefers-contrast") && rule.cssText.includes(".tiny-local-"));
|
|
const reducedMotionRules = Array.from(reducedMotion?.rule.cssRules || []);
|
|
const disablesTransitions = reducedMotionRules.some((rule) => rule.style?.transitionProperty === "none");
|
|
const disablesAnimations = reducedMotionRules.some((rule) => rule.style?.animationName === "none");
|
|
assert(disablesTransitions && disablesAnimations, "Loaded CSS is missing reduced-motion overrides");
|
|
assert(!!highContrast, "Loaded CSS is missing high-contrast overrides");
|
|
|
|
popoutLeaf = app.workspace.openPopoutLeaf();
|
|
await sleep(500);
|
|
const popoutContainer = popoutLeaf?.view?.containerEl;
|
|
const popoutDocument = popoutContainer?.doc || popoutContainer?.ownerDocument;
|
|
assert(popoutContainer && popoutDocument && popoutDocument !== document, "Obsidian popout leaf did not expose a distinct document");
|
|
const popoutWindow = popoutDocument.defaultView;
|
|
assert(!!popoutWindow, "Obsidian popout document did not expose its owning window");
|
|
let cancelledPopoutTimerFired = false;
|
|
const popoutTimer = p.settingsTab.setWindowTimeout(() => {
|
|
cancelledPopoutTimerFired = true;
|
|
}, 100, popoutWindow);
|
|
p.settingsTab.clearWindowTimeout(popoutTimer, popoutWindow);
|
|
await sleep(150);
|
|
assert(!cancelledPopoutTimerFired, "Settings timer was not cancelled through its owning popout window");
|
|
const popoutTarget = popoutContainer.createDiv({ cls: "tiny-local-runtime-popout-tooltip-probe" });
|
|
const savings = (await p.getStatsSnapshot()).savings;
|
|
p.settingsTab.createSavingsTooltip(popoutTarget, savings);
|
|
popoutTarget.focus();
|
|
await sleep(150);
|
|
assert(!!popoutDocument.querySelector(".tiny-local-savings-tooltip"), "Savings tooltip did not render in its owning popout document");
|
|
assert(!document.querySelector(".tiny-local-savings-tooltip"), "Popout savings tooltip leaked into the main document");
|
|
p.settingsTab.cleanupSavingsTooltips();
|
|
popoutTarget.remove();
|
|
|
|
return { light, dark, reducedMotion: true, highContrast: true, popoutOwned: true, popoutTimerOwned: true };
|
|
} finally {
|
|
document.body.classList.remove("theme-light", "theme-dark");
|
|
if (originalThemeClasses.light) document.body.classList.add("theme-light");
|
|
if (originalThemeClasses.dark) document.body.classList.add("theme-dark");
|
|
p.settingsTab.cleanupSavingsTooltips();
|
|
popoutLeaf?.detach?.();
|
|
}
|
|
});
|
|
|
|
await check("settings: text inputs and sliders update runtime settings", async () => {
|
|
try {
|
|
const root = await openSettings();
|
|
const textInputs = Array.from(root.querySelectorAll("input[type='text']"));
|
|
const rangeInputs = Array.from(root.querySelectorAll("input[type='range']"));
|
|
assert(textInputs.length >= 2 && rangeInputs.length >= 5, "Settings controls missing");
|
|
|
|
dispatchInput(textInputs[0], "42-58");
|
|
assert(p.settings.pngQuality.min === 42 && p.settings.pngQuality.max === 58, "PNG quality text input did not update settings", p.settings.pngQuality);
|
|
const oldOutput = p.settings.outputFolder;
|
|
dispatchInput(textInputs[1], "../bad-output");
|
|
await sleep(100);
|
|
assert(p.settings.outputFolder === oldOutput, "Invalid output folder was accepted", { oldOutput, current: p.settings.outputFolder });
|
|
dispatchInput(textInputs[1], `${qaRoot}/Compressed`);
|
|
assert(p.settings.outputFolder === `${qaRoot}/Compressed`, "Output folder text input did not update settings", p.settings.outputFolder);
|
|
|
|
const sliderAssertions = [
|
|
[0, 55, () => p.settings.jpegQuality === 55, "jpegQuality"],
|
|
[1, 20, () => p.settings.autoBackgroundThreshold === 20 && p.backgroundCompressionService.AUTO_BACKGROUND_THRESHOLD === 20, "autoBackgroundThreshold"],
|
|
[2, 3, () => p.settings.inactivityThresholdMinutes === 3 && p.backgroundCompressionService.USER_INACTIVITY_THRESHOLD === 180000, "inactivityThresholdMinutes"],
|
|
[3, 9, () => p.settings.autoBackupsRetentionDays === 9, "autoBackupsRetentionDays"],
|
|
[4, 2, () => p.settings.autoMoveCompressedThreshold === 2, "autoMoveCompressedThreshold"]
|
|
];
|
|
for (const [index, value, predicate, key] of sliderAssertions) {
|
|
dispatchInput(rangeInputs[index], value);
|
|
await sleep(80);
|
|
assert(predicate(), `Slider did not update ${key}`, { key, value, current: p.settings[key] });
|
|
}
|
|
|
|
await sleep(800);
|
|
return {
|
|
pngQuality: clone(p.settings.pngQuality),
|
|
outputFolder: p.settings.outputFolder,
|
|
slidersTested: sliderAssertions.length
|
|
};
|
|
} finally {
|
|
await restoreQaDefaults();
|
|
}
|
|
});
|
|
|
|
await check("settings: toggles update runtime settings", async () => {
|
|
await restoreQaDefaults();
|
|
try {
|
|
const root = await openSettings();
|
|
const freshToggles = Array.from(root.querySelectorAll(".checkbox-container"));
|
|
assert(freshToggles.length >= 4, "Settings toggles missing", { count: freshToggles.length });
|
|
await setToggle(freshToggles[0], true);
|
|
assert(p.settings.autoCompressNewFiles === true, "autoCompressNewFiles toggle did not update");
|
|
await setToggle(freshToggles[1], true);
|
|
assert(p.settings.autoBackgroundCompression === true, "background toggle did not update");
|
|
await setToggle(freshToggles[2], true);
|
|
assert(p.settings.autoBackupsRetentionEnabled === true, "retention toggle did not update");
|
|
await setToggle(freshToggles[3], true);
|
|
assert(p.settings.autoMoveCompressedEnabled === true, "auto-move toggle did not update");
|
|
await sleep(650);
|
|
return {
|
|
togglesTested: 4
|
|
};
|
|
} finally {
|
|
await restoreQaDefaults();
|
|
}
|
|
});
|
|
|
|
await check("settings: allowed roots add modal and clear icon work", async () => {
|
|
try {
|
|
p.settings.allowedRoots = [`${qaRoot}/`];
|
|
await p.saveSettings();
|
|
let root = await openSettings();
|
|
let buttons = Array.from(root.querySelectorAll("button"));
|
|
const rootPill = root.querySelector(".tiny-local-roots-pill");
|
|
assert(rootPill?.tagName === "BUTTON" && !!rootPill.getAttribute("aria-label"), "Allowed-root removal pill is not an accessible button");
|
|
const addButton = buttons.find((button) => !button.classList.contains("tiny-local-roots-pill"));
|
|
assert(!!addButton, "Allowed-roots Add button missing");
|
|
clickElement(addButton);
|
|
await sleep(300);
|
|
assert(!!document.querySelector(".modal-container .modal"), "Allowed-roots modal did not open");
|
|
await closeTopModal();
|
|
root = await openSettings();
|
|
const clearIcon = root.querySelector(".tiny-local-roots-clear");
|
|
assert(!!clearIcon, "Allowed-roots clear icon missing");
|
|
clickElement(clearIcon);
|
|
await sleep(400);
|
|
assert(Array.isArray(p.settings.allowedRoots) && p.settings.allowedRoots.length === 0, "Allowed roots were not cleared", p.settings.allowedRoots);
|
|
return { modalOpened: true, clearWorked: true };
|
|
} finally {
|
|
await restoreQaDefaults();
|
|
}
|
|
});
|
|
|
|
await check("settings: cache restore dropdown is populated and dispatches restore", async () => {
|
|
const markerMtime = Date.now();
|
|
const markerKey = p.cache.buildCacheKey(`${qaRoot}/restore-marker.jpg`, "restore-marker", markerMtime);
|
|
p.cache.cacheData.entries[markerKey] = {
|
|
path: `${qaRoot}/restore-marker.jpg`,
|
|
md5: "restore-marker",
|
|
mtime: markerMtime,
|
|
timestamp: markerMtime,
|
|
lastAccessMs: markerMtime,
|
|
state: "processed",
|
|
originalSize: 123,
|
|
sourceMtime: markerMtime,
|
|
sourceSize: 123
|
|
};
|
|
await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
|
|
await p.cache.createBackup();
|
|
const backups = await p.cache.getAvailableBackups();
|
|
assert(backups.length > 0, "No cache backups available after createBackup");
|
|
const targetBackup = backups[0];
|
|
const originalRestore = p.cache.restoreFromBackup.bind(p.cache);
|
|
let restoredValue = null;
|
|
p.cache.restoreFromBackup = async (value) => {
|
|
restoredValue = value;
|
|
return true;
|
|
};
|
|
try {
|
|
const root = await openSettings();
|
|
const select = root.querySelector("select");
|
|
assert(!!select, "Cache restore dropdown missing");
|
|
assert(Array.from(select.options).some((option) => option.value === targetBackup), "Created backup is missing from dropdown", { targetBackup });
|
|
select.value = targetBackup;
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
await sleep(600);
|
|
assert(restoredValue === targetBackup, "Dropdown did not dispatch restoreFromBackup", { restoredValue, targetBackup });
|
|
} finally {
|
|
p.cache.restoreFromBackup = originalRestore;
|
|
}
|
|
return { backupCount: backups.length, targetBackup };
|
|
});
|
|
|
|
await check("settings: all action buttons dispatch their intended operations", async () => {
|
|
const calls = {
|
|
refresh: 0,
|
|
clearCache: 0,
|
|
rebuildIndex: 0,
|
|
statusUpdate: 0,
|
|
move: 0,
|
|
clearBackups: 0,
|
|
openPath: [],
|
|
showBackupsList: 0
|
|
};
|
|
const originals = {
|
|
forceRefreshCache: p.forceRefreshCache,
|
|
clearCache: p.cache.clearCache,
|
|
rebuildImageIndex: p.rebuildImageIndex,
|
|
statusUpdate: p.statusBarController.update,
|
|
moveCompressedToFiles: p.moveService.moveCompressedToFiles,
|
|
clearOriginalFilesBackups: p.clearOriginalFilesBackups,
|
|
showCacheBackupsList: p.showCacheBackupsList,
|
|
openPath: electron.shell.openPath
|
|
};
|
|
p.forceRefreshCache = async () => { calls.refresh++; };
|
|
p.cache.clearCache = async () => { calls.clearCache++; };
|
|
p.rebuildImageIndex = async () => { calls.rebuildIndex++; };
|
|
p.statusBarController.update = async () => { calls.statusUpdate++; };
|
|
p.moveService.moveCompressedToFiles = async () => { calls.move++; };
|
|
p.clearOriginalFilesBackups = async () => { calls.clearBackups++; };
|
|
p.showCacheBackupsList = async () => { calls.showBackupsList++; };
|
|
electron.shell.openPath = async (targetPath) => {
|
|
calls.openPath.push(targetPath);
|
|
return "";
|
|
};
|
|
try {
|
|
const root = await openSettings();
|
|
const buttons = Array.from(root.querySelectorAll("button"))
|
|
.filter((button) => !button.classList.contains("tiny-local-roots-pill"));
|
|
assert(buttons.length >= 8, "Expected at least 8 settings buttons", { count: buttons.length, texts: buttons.map((button) => button.textContent.trim()) });
|
|
const buttonIndexes = {
|
|
refreshUncompressed: 1,
|
|
clearCache: 2,
|
|
refreshCache: 3,
|
|
moveCompressed: 4,
|
|
clearImageBackups: 5,
|
|
openImageBackups: 6,
|
|
openCacheBackups: 7
|
|
};
|
|
for (const index of Object.values(buttonIndexes)) {
|
|
assert(buttons[index], `Missing button index ${index}`, { count: buttons.length });
|
|
clickElement(buttons[index]);
|
|
await sleep(450);
|
|
}
|
|
assert(calls.refresh === 2, "Refresh buttons did not dispatch forceRefreshCache twice", calls);
|
|
assert(calls.clearCache === 1, "Clear cache button did not dispatch", calls);
|
|
assert(calls.move === 1, "Move button did not dispatch", calls);
|
|
assert(calls.clearBackups === 1, "Clear image backups button did not dispatch", calls);
|
|
assert(calls.openPath.length === 1, "Open image backups button did not open path", calls);
|
|
assert(calls.showBackupsList === 1, "Open cache backups button did not dispatch", calls);
|
|
} finally {
|
|
p.forceRefreshCache = originals.forceRefreshCache;
|
|
p.cache.clearCache = originals.clearCache;
|
|
p.rebuildImageIndex = originals.rebuildImageIndex;
|
|
p.statusBarController.update = originals.statusUpdate;
|
|
p.moveService.moveCompressedToFiles = originals.moveCompressedToFiles;
|
|
p.clearOriginalFilesBackups = originals.clearOriginalFilesBackups;
|
|
p.showCacheBackupsList = originals.showCacheBackupsList;
|
|
electron.shell.openPath = originals.openPath;
|
|
await restoreQaDefaults();
|
|
await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
|
|
await p.rebuildImageIndex("runtime-qa-after-button-wiring");
|
|
await p.statusBarController.update();
|
|
}
|
|
return calls;
|
|
});
|
|
|
|
await check("cache: clear, compaction, backup, and restore work", async () => {
|
|
await p.cache.clearCache();
|
|
assert(p.cache.getCacheStats().total === 0, "clearCache did not empty cache", p.cache.getCacheStats());
|
|
const tiny = await createSmallJpeg(`${qaRoot}/Cache/tiny-too-small.jpg`);
|
|
await p.compressFile(tiny);
|
|
await waitForCompressionIdle();
|
|
let fresh = await p.cache.getFreshEntryForFile(tiny);
|
|
assert(fresh?.entry?.state === "skipped" && fresh.entry.skipReason === "too_small", "Too-small image was not cached as skipped", fresh?.entry);
|
|
const legacySource = await createSmallJpeg(`${qaRoot}/Cache/legacy-source.jpg`);
|
|
|
|
const missingMtime = Date.now();
|
|
const missingModernKey = p.cache.buildCacheKey(`${qaRoot}/Cache/missing.jpg`, "missing-modern", missingMtime);
|
|
const missingLegacyKey = `legacy:${qaRoot}/Cache/missing-legacy.jpg`;
|
|
const existingLegacyKey = `legacy:${legacySource.path}`;
|
|
const staleKey = p.cache.buildCacheKey(tiny.path, "stale-modern", Math.max(1, tiny.stat.mtime - 1));
|
|
p.cache.cacheData.entries[missingModernKey] = {
|
|
path: `${qaRoot}/Cache/missing.jpg`,
|
|
md5: "missing-modern",
|
|
mtime: missingMtime,
|
|
timestamp: missingMtime,
|
|
state: "skipped",
|
|
originalSize: 100,
|
|
sourceMtime: missingMtime,
|
|
sourceSize: 100
|
|
};
|
|
p.cache.cacheData.entries[missingLegacyKey] = {
|
|
path: `${qaRoot}/Cache/missing-legacy.jpg`,
|
|
state: "processed",
|
|
timestamp: 1
|
|
};
|
|
p.cache.cacheData.entries[existingLegacyKey] = {
|
|
path: legacySource.path,
|
|
state: "processed",
|
|
originalSize: legacySource.stat.size,
|
|
timestamp: 1
|
|
};
|
|
p.cache.cacheData.entries[staleKey] = {
|
|
path: tiny.path,
|
|
md5: "stale-modern",
|
|
state: "skipped",
|
|
sourceMtime: Math.max(1, tiny.stat.mtime - 1),
|
|
sourceSize: tiny.stat.size + 1,
|
|
timestamp: 1
|
|
};
|
|
await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
|
|
const compacted = await p.cache.compactCache();
|
|
assert(compacted.missingFilesRemoved === 1 && compacted.supersededRemoved === 1, "Cache compaction returned wrong result", compacted);
|
|
assert(!p.cache.cacheData.entries[missingModernKey] && !p.cache.cacheData.entries[staleKey], "Cache compaction kept removable modern entries", compacted);
|
|
assert(!!p.cache.cacheData.entries[missingLegacyKey] && !!p.cache.cacheData.entries[fresh.cacheKey], "Cache compaction removed legacy or current entries", compacted);
|
|
assert(!!p.cache.cacheData.entries[existingLegacyKey] && await p.cache.getFreshEntryForFile(legacySource) === null, "Existing legacy cache entry was deleted or still treated as processed");
|
|
|
|
const markerMtime = Date.now();
|
|
const markerKey = p.cache.buildCacheKey(`${qaRoot}/Cache/backup-marker.jpg`, "backup-marker", markerMtime);
|
|
p.cache.cacheData.entries[markerKey] = {
|
|
path: `${qaRoot}/Cache/backup-marker.jpg`,
|
|
md5: "backup-marker",
|
|
mtime: markerMtime,
|
|
timestamp: markerMtime,
|
|
lastAccessMs: markerMtime,
|
|
state: "processed",
|
|
originalSize: 456,
|
|
sourceMtime: markerMtime,
|
|
sourceSize: 456
|
|
};
|
|
await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
|
|
await p.cache.createBackup();
|
|
const backups = await p.cache.getAvailableBackups();
|
|
assert(backups.length > 0, "Cache backup list is empty");
|
|
const targetBackup = backups[0];
|
|
delete p.cache.cacheData.entries[markerKey];
|
|
await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
|
|
const restored = await p.cache.restoreFromBackup(targetBackup);
|
|
assert(restored === true, "restoreFromBackup returned false", { targetBackup });
|
|
assert(!!p.cache.cacheData.entries[markerKey], "Cache backup did not restore marker entry", { targetBackup });
|
|
assert(p.cache.isValidBackupFileName("../bad.json") === false, "Invalid backup filename was accepted");
|
|
return { compacted, backupCount: backups.length, targetBackup };
|
|
});
|
|
|
|
await check("compression: direct JPG, JPEG, and PNG produce smaller outputs and pending cache entries", async () => {
|
|
await p.cache.clearCache();
|
|
await p.rebuildImageIndex("runtime-qa-compression-start");
|
|
const jpg = await createImage(`${qaRoot}/Direct/direct-jpg.jpg`, "jpg", 1);
|
|
const jpeg = await createImage(`${qaRoot}/Direct/direct-jpeg.jpeg`, "jpg", 2);
|
|
const png = await createImage(`${qaRoot}/Direct/direct-png.png`, "png", 3);
|
|
await p.compressFile(jpg);
|
|
const jpgResult = await assertCompressed(jpg, "direct jpg");
|
|
await p.compressFile(jpeg);
|
|
const jpegResult = await assertCompressed(jpeg, "direct jpeg");
|
|
await p.compressFile(png);
|
|
const pngResult = await assertCompressed(png, "direct png");
|
|
const validation = await p.validateFileForCompression(jpg);
|
|
assert(validation.valid === false, "Already-compressed file was still valid for compression", validation);
|
|
return { jpgResult, jpegResult, pngResult, alreadyCompressedValidation: validation };
|
|
});
|
|
|
|
await check("compression: file context menu action compresses selected image", async () => {
|
|
const file = await createImage(`${qaRoot}/Context/File/context-file.jpg`, "jpg", 4);
|
|
const items = [];
|
|
const menu = {
|
|
addItem(callback) {
|
|
const item = {
|
|
title: "",
|
|
icon: "",
|
|
callback: null,
|
|
setTitle(value) { this.title = value; return this; },
|
|
setIcon(value) { this.icon = value; return this; },
|
|
onClick(value) { this.callback = value; return this; }
|
|
};
|
|
callback(item);
|
|
items.push(item);
|
|
}
|
|
};
|
|
p.addContextMenu(menu, file);
|
|
assert(items.length === 1 && typeof items[0].callback === "function", "File context menu item missing", items);
|
|
await items[0].callback();
|
|
return await assertCompressed(file, "file context menu");
|
|
});
|
|
|
|
await check("compression: folder context menu action compresses images in the folder", async () => {
|
|
const file = await createImage(`${qaRoot}/Context/Folder/context-folder.jpg`, "jpg", 5);
|
|
const folder = app.vault.getAbstractFileByPath(`${qaRoot}/Context/Folder`);
|
|
const items = [];
|
|
const menu = {
|
|
addItem(callback) {
|
|
const item = {
|
|
title: "",
|
|
icon: "",
|
|
callback: null,
|
|
setTitle(value) { this.title = value; return this; },
|
|
setIcon(value) { this.icon = value; return this; },
|
|
onClick(value) { this.callback = value; return this; }
|
|
};
|
|
callback(item);
|
|
items.push(item);
|
|
}
|
|
};
|
|
p.addFolderContextMenu(menu, folder);
|
|
assert(items.length === 1 && typeof items[0].callback === "function", "Folder context menu item missing", items);
|
|
await items[0].callback();
|
|
return await assertCompressed(file, "folder context menu");
|
|
});
|
|
|
|
await check("commands: compress images in active note", async () => {
|
|
const noteImageA = await createImage(`${qaRoot}/Note/note-a.jpg`, "jpg", 6);
|
|
const noteImageB = await createImage(`${qaRoot}/Note/note-b.png`, "png", 7);
|
|
const note = await createTextFile(`${qaRoot}/Note/note.md`, `# Runtime QA\n\n![[${noteImageA.path}]]\n\n![[${noteImageB.path}]]\n`);
|
|
await app.workspace.getLeaf(false).openFile(note);
|
|
await sleep(1200);
|
|
const discovered = await p.imageScanner.getImagesInNote(note);
|
|
assert(discovered.some((file) => file.path === noteImageA.path), "Image scanner did not find note JPG", discovered.map((file) => file.path));
|
|
assert(discovered.some((file) => file.path === noteImageB.path), "Image scanner did not find note PNG", discovered.map((file) => file.path));
|
|
await runCommand("compress-images-in-note");
|
|
const a = await assertCompressed(noteImageA, "command note jpg");
|
|
const b = await assertCompressed(noteImageB, "command note png");
|
|
return { discovered: discovered.map((file) => file.path), outputs: [a, b] };
|
|
});
|
|
|
|
await check("commands: compress images in selected folder", async () => {
|
|
const file = await createImage(`${qaRoot}/FolderCommand/folder-command.jpg`, "jpg", 8);
|
|
const originalSelector = p.showFolderSelector;
|
|
p.showFolderSelector = async () => `${qaRoot}/FolderCommand`;
|
|
try {
|
|
await runCommand("compress-images-in-folder");
|
|
} finally {
|
|
p.showFolderSelector = originalSelector;
|
|
}
|
|
return await assertCompressed(file, "command folder");
|
|
});
|
|
|
|
await check("folder selector: cancel, select, and managed close clean up", async () => {
|
|
const folderChoices = ["/", `${qaRoot}/FolderCommand`];
|
|
app.setting?.close?.();
|
|
p.closeManagedModals();
|
|
await sleep(200);
|
|
assert(p.settingsTab?._isVisible === false, "Plugin-owned settings tab remained visible before standalone modal QA");
|
|
assert(p.managedModals.size === 0, "A prior managed modal remained open before standalone modal QA");
|
|
const focusReturnProbe = document.body.createEl("button", { text: "Runtime QA modal trigger" });
|
|
focusReturnProbe.type = "button";
|
|
focusReturnProbe.focus();
|
|
assert(document.activeElement === focusReturnProbe, "Runtime modal trigger could not receive focus before opening the folder selector");
|
|
try {
|
|
const cancelPromise = p.showFolderSelector(folderChoices);
|
|
await sleep(250);
|
|
let select = document.querySelector(".tiny-local-folder-select-control");
|
|
assert(!!select, "Folder selector did not render a select control for cancel path");
|
|
assert(select.getAttribute("aria-label"), "Folder selector select is missing aria-label");
|
|
assert(document.activeElement === select, "Folder selector did not focus its first actionable control");
|
|
clickElement(document.querySelector("#cancel-folder"));
|
|
const cancelResult = await cancelPromise;
|
|
assert(cancelResult === null, "Folder selector cancel did not resolve null", { cancelResult });
|
|
await sleep(150);
|
|
assert(!document.querySelector(".tiny-local-folder-select-control"), "Folder selector DOM remained after cancel");
|
|
assert(document.activeElement === focusReturnProbe, "Folder selector did not return focus to its trigger");
|
|
|
|
const selectPromise = p.showFolderSelector(folderChoices);
|
|
await sleep(250);
|
|
select = document.querySelector(".tiny-local-folder-select-control");
|
|
assert(!!select, "Folder selector did not render a select control for selection path");
|
|
select.value = `${qaRoot}/FolderCommand`;
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
clickElement(document.querySelector("#select-folder"));
|
|
const selectedResult = await selectPromise;
|
|
assert(selectedResult === `${qaRoot}/FolderCommand`, "Folder selector returned the wrong selected folder", { selectedResult });
|
|
await sleep(150);
|
|
assert(!document.querySelector(".tiny-local-folder-select-control"), "Folder selector DOM remained after selection");
|
|
|
|
const cleanupPromise = p.showFolderSelector(folderChoices);
|
|
await sleep(250);
|
|
assert(!!document.querySelector(".tiny-local-folder-select-control"), "Folder selector did not render before managed close");
|
|
p.closeManagedModals();
|
|
const cleanupResult = await cleanupPromise;
|
|
assert(cleanupResult === null, "Managed modal cleanup did not resolve folder selector with null", { cleanupResult });
|
|
await sleep(150);
|
|
assert(!document.querySelector(".tiny-local-folder-select-control"), "Folder selector DOM remained after managed close");
|
|
|
|
const moveProgressModal = p.moveService.showMoveProgressModal(1);
|
|
assert(p.managedModals.has(moveProgressModal), "Move progress modal was not tracked");
|
|
const moveProgressBar = moveProgressModal.contentEl.querySelector('[role="progressbar"]');
|
|
const moveProgressStatus = moveProgressModal.contentEl.querySelector('[role="status"]');
|
|
assert(moveProgressBar?.getAttribute("aria-valuemin") === "0" && moveProgressBar?.getAttribute("aria-valuemax") === "1", "Move progress modal is missing bounded ARIA values");
|
|
assert(moveProgressStatus?.getAttribute("aria-live") === "polite", "Move progress modal is missing polite status announcements");
|
|
p.closeManagedModals();
|
|
assert(!p.managedModals.has(moveProgressModal), "Move progress modal remained tracked after cleanup");
|
|
await sleep(100);
|
|
assert(!document.querySelector(".tiny-local-move-progress-modal"), "Move progress modal DOM remained after managed close");
|
|
|
|
return { cancelResult, selectedResult, cleanupResult, initialFocus: true, focusReturned: true, moveProgressCleaned: true };
|
|
} finally {
|
|
p.closeManagedModals();
|
|
focusReturnProbe.remove();
|
|
}
|
|
});
|
|
|
|
await check("commands: compress all images respects allowed roots and output folder exclusion", async () => {
|
|
const file = await createImage(`${qaRoot}/All/all-command.jpg`, "jpg", 9);
|
|
await runCommand("compress-all-images", 120000);
|
|
const result = await assertCompressed(file, "command all");
|
|
const outputFiles = app.vault.getFiles().filter((candidate) => candidate.path.startsWith(`${qaRoot}/Compressed/`));
|
|
const uncompressed = await p.getImageFiles();
|
|
assert(!uncompressed.some((candidate) => candidate.path.startsWith(`${qaRoot}/Compressed/`)), "Output folder files are treated as compression inputs", {
|
|
outputFiles: outputFiles.map((candidate) => candidate.path),
|
|
uncompressed: uncompressed.map((candidate) => candidate.path)
|
|
});
|
|
return { result, outputVaultFilesSeen: outputFiles.length };
|
|
});
|
|
|
|
await check("compression: background batch compression works without modal workflow", async () => {
|
|
const file = await createImage(`${qaRoot}/Background/background.jpg`, "jpg", 10);
|
|
await p.processBatchCompressionBackground([file]);
|
|
return await assertCompressed(file, "background batch");
|
|
});
|
|
|
|
await check("compression: new-file auto compression queue drains and compresses", async () => {
|
|
try {
|
|
p.settings.autoCompressNewFiles = true;
|
|
await p.saveSettings();
|
|
const file = await createImage(`${qaRoot}/Auto/auto-new-file.jpg`, "jpg", 11);
|
|
await p.handleNewFile(file);
|
|
await sleep(100);
|
|
await p.drainNewFileCompressionBatch();
|
|
return await assertCompressed(file, "auto new file");
|
|
} finally {
|
|
await restoreQaDefaults();
|
|
}
|
|
});
|
|
|
|
await check("validation: unsupported and too-small files are rejected safely", async () => {
|
|
const textFile = await createTextFile(`${qaRoot}/Validation/not-image.txt`, "not an image");
|
|
const unsupported = await p.validateFileForCompression(textFile);
|
|
assert(unsupported.valid === false, "Unsupported text file was accepted", unsupported);
|
|
const small = await createSmallJpeg(`${qaRoot}/Validation/small.jpg`);
|
|
const tooSmall = await p.validateFileForCompression(small);
|
|
assert(tooSmall.valid === false && tooSmall.skipped === true, "Too-small image was not rejected as skipped", tooSmall);
|
|
const fresh = await p.cache.getFreshEntryForFile(small);
|
|
assert(fresh?.entry?.skipReason === "too_small", "Too-small validation did not write cache skip entry", fresh?.entry);
|
|
return { unsupported, tooSmall, skipEntry: fresh.entry };
|
|
});
|
|
|
|
await check("status bar: text, aria label, attention state, and menu buttons work", async () => {
|
|
app.setting?.close?.();
|
|
await sleep(200);
|
|
assert(p.settingsTab?._isVisible === false, "Plugin-owned settings tab remained visible after settings close");
|
|
await p.statusBarController.update();
|
|
const statusText = p.statusBarItem?.getText?.() || p.statusBarItem?.textContent || "";
|
|
const aria = p.statusBarItem?.getAttribute?.("aria-label") || "";
|
|
assert(statusText.includes("/"), "Status bar text does not contain counts", { statusText, aria });
|
|
assert(aria.includes(statusText.trim()), "Status bar aria-label does not include status text", { statusText, aria });
|
|
assert(!p.statusBarItem?.getAttribute?.("title"), "Status bar item has a native title tooltip that can overlap Obsidian's tooltip", {
|
|
title: p.statusBarItem?.getAttribute?.("title")
|
|
});
|
|
assert(p.statusBarItem?.getAttribute?.("role") === "button", "Status bar item is missing role=button");
|
|
assert(p.statusBarItem?.getAttribute?.("tabindex") === "0", "Status bar item is missing tabindex=0");
|
|
assert(p.statusBarItem?.getAttribute?.("aria-haspopup") === "menu", "Status bar item is missing aria-haspopup=menu");
|
|
|
|
const originalNote = p.compressImagesInNote;
|
|
const originalAll = p.compressAllImages;
|
|
const originalMove = p.moveService.moveCompressedToFiles;
|
|
const originalCount = p.moveService.getCompressedFilesCount;
|
|
const calls = { note: 0, all: 0, move: 0 };
|
|
p.compressImagesInNote = async () => { calls.note++; };
|
|
p.compressAllImages = async () => { calls.all++; };
|
|
p.moveService.moveCompressedToFiles = async () => { calls.move++; };
|
|
p.moveService.getCompressedFilesCount = async () => 1;
|
|
try {
|
|
const fakeEvent = {
|
|
target: {
|
|
getBoundingClientRect: () => ({ left: 80, top: 700, bottom: 720, width: 140, height: 22 })
|
|
}
|
|
};
|
|
for (const [index, key] of [[0, "note"], [1, "all"], [2, "move"]]) {
|
|
await p.statusBarController.showMenu(fakeEvent);
|
|
const menu = document.querySelector(".tiny-local-status-menu");
|
|
assert(!!menu, "Status menu did not open");
|
|
const menuWindow = menu.ownerDocument?.defaultView || window;
|
|
const immediateStyle = menuWindow.getComputedStyle(menu);
|
|
const immediateRect = menu.getBoundingClientRect();
|
|
const expectedLeft = Number.parseFloat(immediateStyle.getPropertyValue("--local-image-compress-status-menu-left"));
|
|
const expectedTop = Number.parseFloat(immediateStyle.getPropertyValue("--local-image-compress-status-menu-top"));
|
|
const transitionProperties = immediateStyle.transitionProperty
|
|
.split(",")
|
|
.map((property) => property.trim().toLowerCase());
|
|
const transitionDurationsMs = immediateStyle.transitionDuration
|
|
.split(",")
|
|
.map((duration) => {
|
|
const trimmed = duration.trim().toLowerCase();
|
|
const numeric = Number.parseFloat(trimmed);
|
|
return trimmed.endsWith("ms") ? numeric : numeric * 1000;
|
|
});
|
|
const hasAnimatedPositionTransition = transitionProperties.some((property, propertyIndex) => {
|
|
const durationMs = transitionDurationsMs[propertyIndex % transitionDurationsMs.length] || 0;
|
|
return durationMs > 0 && (property === "all" || property === "left" || property === "top" || property === "transform");
|
|
});
|
|
assert(
|
|
!hasAnimatedPositionTransition,
|
|
"Status menu still transitions dynamic position properties",
|
|
{ transitionProperty: immediateStyle.transitionProperty, transitionDuration: immediateStyle.transitionDuration }
|
|
);
|
|
assert(
|
|
Number.isFinite(expectedLeft)
|
|
&& Number.isFinite(expectedTop)
|
|
&& Math.abs(immediateRect.left - expectedLeft) <= 1
|
|
&& Math.abs(immediateRect.top - expectedTop) <= 1,
|
|
"Status menu did not render at its computed position immediately",
|
|
{
|
|
actual: { left: immediateRect.left, top: immediateRect.top },
|
|
expected: { left: expectedLeft, top: expectedTop }
|
|
}
|
|
);
|
|
await sleep(100);
|
|
assert(menu.getAttribute("role") === "menu", "Status menu is missing role=menu");
|
|
const items = Array.from(menu.querySelectorAll(".tiny-local-status-menu-item"));
|
|
assert(items.length >= 3, "Status menu did not include all action items", { itemTexts: items.map((item) => item.textContent.trim()) });
|
|
for (const item of items) {
|
|
assert(item.tagName === "BUTTON", "Status menu action is not rendered as a button", { tagName: item.tagName, text: item.textContent.trim() });
|
|
assert(item.getAttribute("role") === "menuitem", "Status menu action is missing role=menuitem", { text: item.textContent.trim() });
|
|
}
|
|
clickElement(items[index]);
|
|
await sleep(250);
|
|
assert(calls[key] === 1, `Status menu item ${key} did not dispatch`, calls);
|
|
}
|
|
|
|
p.statusBarController.closeMenu();
|
|
const viewportWindow = document.defaultView || window;
|
|
const edgeEvent = {
|
|
target: {
|
|
getBoundingClientRect: () => ({
|
|
left: viewportWindow.innerWidth - 2,
|
|
top: viewportWindow.innerHeight - 24,
|
|
bottom: viewportWindow.innerHeight - 4,
|
|
width: 2,
|
|
height: 20
|
|
})
|
|
}
|
|
};
|
|
await p.statusBarController.showMenu(edgeEvent);
|
|
const edgeMenu = await waitForStatusMenu();
|
|
assert(!!edgeMenu, "Status menu did not open for right-edge viewport check");
|
|
const edgeRect = edgeMenu.getBoundingClientRect();
|
|
assert(edgeRect.left >= 0 && edgeRect.right <= viewportWindow.innerWidth && edgeRect.top >= 0 && edgeRect.bottom <= viewportWindow.innerHeight, "Status menu overflowed viewport", {
|
|
rect: { left: edgeRect.left, right: edgeRect.right, top: edgeRect.top, bottom: edgeRect.bottom },
|
|
viewport: { width: viewportWindow.innerWidth, height: viewportWindow.innerHeight }
|
|
});
|
|
const edgeItem = edgeMenu.querySelector(".tiny-local-status-menu-item");
|
|
assert(!!edgeItem, "Status menu right-edge check did not find a menu item");
|
|
const edgeItemStyle = viewportWindow.getComputedStyle(edgeItem);
|
|
assert(edgeItemStyle.backgroundColor === "rgba(0, 0, 0, 0)" || edgeItemStyle.backgroundColor === "transparent", "Status menu item has a separate background", {
|
|
backgroundColor: edgeItemStyle.backgroundColor
|
|
});
|
|
assert(edgeItemStyle.boxShadow === "none", "Status menu item still has theme button shadow", { boxShadow: edgeItemStyle.boxShadow });
|
|
p.statusBarController.closeMenu();
|
|
|
|
p.statusBarController.closeMenu();
|
|
const originalStatusBarRect = p.statusBarItem.getBoundingClientRect;
|
|
p.statusBarItem.getBoundingClientRect = () => ({ left: 80, top: 700, bottom: 720, width: 140, height: 22 });
|
|
try {
|
|
const statusBarWindow = p.statusBarItem.ownerDocument?.defaultView || window;
|
|
p.statusBarItem.focus();
|
|
const noteCallsBeforeKeyboard = calls.note;
|
|
p.statusBarItem.dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
const keyboardMenu = await waitForStatusMenu();
|
|
assert(!!keyboardMenu, "Status menu did not open with keyboard context");
|
|
assert(p.statusBarItem.getAttribute("aria-expanded") === "true", "Status bar aria-expanded did not become true after keyboard open");
|
|
const keyboardItems = Array.from(keyboardMenu.querySelectorAll(".tiny-local-status-menu-item"));
|
|
assert(document.activeElement === keyboardItems[0], "Keyboard-opened status menu did not focus first action");
|
|
const focusedItemStyle = statusBarWindow.getComputedStyle(keyboardItems[0]);
|
|
assert(focusedItemStyle.outlineStyle !== "none" && Number.parseFloat(focusedItemStyle.outlineWidth) > 0, "Focused status menu item has no visible focus indicator", {
|
|
outlineStyle: focusedItemStyle.outlineStyle,
|
|
outlineWidth: focusedItemStyle.outlineWidth
|
|
});
|
|
document.dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
assert(document.activeElement === keyboardItems[1], "ArrowDown did not focus the next status menu action");
|
|
document.dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
assert(document.activeElement === keyboardItems[0], "ArrowUp did not focus the previous status menu action");
|
|
document.dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: "End", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
assert(document.activeElement === keyboardItems[keyboardItems.length - 1], "End did not focus the last status menu action");
|
|
document.dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: "Home", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
assert(document.activeElement === keyboardItems[0], "Home did not focus the first status menu action");
|
|
keyboardItems[0].dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
await sleep(250);
|
|
assert(calls.note === noteCallsBeforeKeyboard + 1, "Focused status menu first item did not dispatch", calls);
|
|
assert(!document.querySelector(".tiny-local-status-menu"), "Status menu did not close after focused item action");
|
|
|
|
await p.statusBarController.showMenu({ keyboard: true, returnFocusTo: p.statusBarItem, target: p.statusBarItem });
|
|
const spaceMenu = await waitForStatusMenu();
|
|
assert(!!spaceMenu, "Status menu did not reopen with keyboard context");
|
|
const spaceItems = Array.from(spaceMenu.querySelectorAll(".tiny-local-status-menu-item"));
|
|
spaceItems[0].dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
await sleep(250);
|
|
assert(calls.note === noteCallsBeforeKeyboard + 2, "Space did not activate the focused status menu action", calls);
|
|
assert(!document.querySelector(".tiny-local-status-menu"), "Status menu did not close after Space activation");
|
|
|
|
await p.statusBarController.showMenu({ keyboard: true, returnFocusTo: p.statusBarItem, target: p.statusBarItem });
|
|
const escapeMenu = await waitForStatusMenu();
|
|
assert(!!escapeMenu, "Status menu did not reopen for Escape verification");
|
|
document.dispatchEvent(new statusBarWindow.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, view: statusBarWindow }));
|
|
await sleep(150);
|
|
assert(!document.querySelector(".tiny-local-status-menu"), "Status menu did not close from Escape key");
|
|
assert(p.statusBarItem.getAttribute("aria-expanded") === "false", "Status bar aria-expanded did not reset after Escape");
|
|
const focusRestoreDeadline = Date.now() + 1000;
|
|
while (document.activeElement !== p.statusBarItem && Date.now() < focusRestoreDeadline) {
|
|
await sleep(50);
|
|
}
|
|
assert(document.activeElement === p.statusBarItem, "Status menu Escape did not restore focus to status bar");
|
|
} finally {
|
|
p.statusBarItem.getBoundingClientRect = originalStatusBarRect;
|
|
}
|
|
} finally {
|
|
p.statusBarController.closeMenu();
|
|
p.compressImagesInNote = originalNote;
|
|
p.compressAllImages = originalAll;
|
|
p.moveService.moveCompressedToFiles = originalMove;
|
|
p.moveService.getCompressedFilesCount = originalCount;
|
|
}
|
|
return { statusText, aria, calls };
|
|
});
|
|
|
|
await check("stats: counts and savings calculator reflect QA images", async () => {
|
|
await p.rebuildImageIndex("runtime-qa-stats");
|
|
const counts = await p.getImageCompressionCounts();
|
|
const stats = await p.getStatsSnapshot();
|
|
const savings = await p.savingsCalculator.calculateSpaceSavings();
|
|
assert(counts.totalImages > 0, "Image counts did not see QA images", counts);
|
|
assert(stats.cacheStats.total > 0, "Stats cache total is empty after compression", stats.cacheStats);
|
|
assert(typeof savings.savedPercentage === "number" && savings.savedPercentage >= 0 && savings.savedPercentage <= 100, "Savings result invalid", savings);
|
|
return { counts, cacheStats: stats.cacheStats, savings };
|
|
});
|
|
|
|
await check("move: command moves compressed outputs to originals and creates backups", async () => {
|
|
const moveProbe = await createImage(`${qaRoot}/Move/move-probe.jpg`, "jpg", 12);
|
|
const originalBefore = (await statRel(moveProbe.path)).size;
|
|
await p.compressFile(moveProbe);
|
|
const compressed = await assertCompressed(moveProbe, "move command setup");
|
|
const movableBefore = await p.moveService.getCompressedFilesCount();
|
|
assert(movableBefore > 0, "No compressed files are movable before move", { movableBefore });
|
|
await runCommand("move-compressed-to-files", 120000);
|
|
const originalAfter = (await statRel(moveProbe.path)).size;
|
|
assert(originalAfter < originalBefore, "Move command did not replace original with smaller compressed file", { originalBefore, originalAfter, compressed });
|
|
assert(!(await existsRel(outputRelFor(moveProbe.path))), "Move command did not remove compressed output", { output: outputRelFor(moveProbe.path) });
|
|
const movedCacheEntry = getStoredCacheEntryWithState(moveProbe.path, "moved");
|
|
assert(!!movedCacheEntry, "Move command did not mark cache entry as moved", {
|
|
entries: p.cache.getEntriesForPath(moveProbe.path)
|
|
});
|
|
const backupDir = p.getBackupStoragePaths().originalFilesBackups;
|
|
const backups = await fs.promises.readdir(backupDir).catch(() => []);
|
|
assert(backups.length > 0, "Move command did not create an image backup directory", { backupDir });
|
|
return { movableBefore, originalBefore, originalAfter, backupCount: backups.length };
|
|
});
|
|
|
|
await check("move: auto-move threshold moves fresh compressed output automatically", async () => {
|
|
try {
|
|
p.settings.autoMoveCompressedEnabled = true;
|
|
p.settings.autoMoveCompressedThreshold = 1;
|
|
await p.saveSettings();
|
|
const file = await createImage(`${qaRoot}/AutoMove/auto-move.jpg`, "jpg", 13);
|
|
const originalBefore = (await statRel(file.path)).size;
|
|
await p.compressFile(file);
|
|
await waitForCompressionIdle(120000);
|
|
const originalAfter = (await statRel(file.path)).size;
|
|
assert(originalAfter < originalBefore, "Auto-move did not replace original with smaller compressed file", { originalBefore, originalAfter });
|
|
assert(!(await existsRel(outputRelFor(file.path))), "Auto-move left compressed output behind", { output: outputRelFor(file.path) });
|
|
const movedCacheEntry = getStoredCacheEntryWithState(file.path, "moved");
|
|
assert(!!movedCacheEntry, "Auto-move did not mark cache entry moved", {
|
|
entries: p.cache.getEntriesForPath(file.path)
|
|
});
|
|
return { originalBefore, originalAfter };
|
|
} finally {
|
|
await restoreQaDefaults();
|
|
}
|
|
});
|
|
|
|
await check("backups: clear original-files backups is safe when redirected to isolated storage", async () => {
|
|
const isolatedStorageRoot = absolute(`${qaRoot}/IsolatedBackupStorage`);
|
|
const originalGetBackupStoragePaths = p.getBackupStoragePaths;
|
|
p.getBackupStoragePaths = () => ({
|
|
root: isolatedStorageRoot,
|
|
backupsRoot: path.join(isolatedStorageRoot, "backups"),
|
|
cacheBackups: path.join(isolatedStorageRoot, "backups", "cache"),
|
|
originalFilesBackups: path.join(isolatedStorageRoot, "backups", "originals")
|
|
});
|
|
try {
|
|
const backupDir = p.getBackupStoragePaths().originalFilesBackups;
|
|
await fs.promises.mkdir(path.join(backupDir, "backup-test"), { recursive: true });
|
|
await fs.promises.writeFile(path.join(backupDir, "backup-test", "file.txt"), "backup");
|
|
await p.clearOriginalFilesBackups();
|
|
const remaining = await fs.promises.readdir(backupDir).catch(() => []);
|
|
assert(remaining.length === 0, "clearOriginalFilesBackups did not empty isolated backup dir", { remaining, backupDir });
|
|
return { backupDir };
|
|
} finally {
|
|
p.getBackupStoragePaths = originalGetBackupStoragePaths;
|
|
}
|
|
});
|
|
|
|
await check("settings: force refresh cache completes and leaves index usable", async () => {
|
|
await p.forceRefreshCache();
|
|
await sleep(200);
|
|
p.closeManagedModals?.();
|
|
assert(p.imageIndex?.isReady?.() === true, "Image index is not ready after forceRefreshCache");
|
|
const counts = await p.getImageCompressionCounts();
|
|
return { counts };
|
|
});
|
|
} catch (error) {
|
|
const failure = {
|
|
name: "runtime-qa: uncaught setup or runner failure",
|
|
status: "fail",
|
|
error: serializeError(error)
|
|
};
|
|
report.checks.push(failure);
|
|
report.failures.push(failure);
|
|
} finally {
|
|
for (let index = cleanupStack.length - 1; index >= 0; index--) {
|
|
try {
|
|
await cleanupStack[index]();
|
|
} catch (error) {
|
|
recordWarning(`cleanup.${index}`, serializeError(error));
|
|
}
|
|
}
|
|
for (let index = restoreStack.length - 1; index >= 0; index--) {
|
|
try {
|
|
await restoreStack[index]();
|
|
} catch (error) {
|
|
recordWarning(`restore.${index}`, serializeError(error));
|
|
}
|
|
}
|
|
try {
|
|
p.closeManagedModals?.();
|
|
p.statusBarController?.closeMenu?.();
|
|
} catch (error) {
|
|
recordWarning("final-ui-close", serializeError(error));
|
|
}
|
|
}
|
|
|
|
report.finishedAt = new Date().toISOString();
|
|
report.summary = {
|
|
passed: report.checks.filter((item) => item.status === "pass").length,
|
|
failed: report.failures.length,
|
|
warnings: report.warnings.length
|
|
};
|
|
writeProgress(report.failures.length > 0 ? "completed-with-failures" : "completed", "runtime QA", {
|
|
summary: report.summary
|
|
});
|
|
globalThis.__tinyLocalFullQaLastReport = report;
|
|
return JSON.stringify(report, null, 2);
|
|
})()
|