Files
Trilium/apps/web-clipper/entrypoints/popup/popup.spec.tsx
T
Elian DoranandClaude Opus 5.5 442f2a07ca perf(clipper): read the whole page only when it is needed
The previous commit parsed every page twice with Defuddle, once as an
article and once as a whole, so that the Article / Whole page switch
was instant. Most clips never use the switch, so every popup and every
shortcut save paid for a parse whose result was thrown away.

`readable-page.js` now reads one way per call. A file injection takes
no arguments, so the script only registers
`globalThis.triliumReadPage(variant)`, and `extractPage(tabId, variant)`
calls it with a second `executeScript({ func, args })` in the same
isolated world. Two script files would have bundled Defuddle twice.

- The popup reads the article, and the whole page only when there is
  no article. Switching to Whole page reads it then, showing "Reading
  the page…" with Save disabled, and `PagePreview` keeps the result in
  `laterReadings` so switching back and forth reads it once. A reading
  is `undefined` until made and `null` when it shows nothing or fails,
  which disables its button and falls back to the article.
- The shortcut and the context menu read the article, and the whole
  page only when there is none: one parse on an article page, as
  before the switch existed.

`readActivePage()` falls back with consecutive `if`s rather than
`article ?? await extractPage(...)`: written with `??`, v8 recorded a
negative count for the code after it, and the branch gate failed with
every case tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 12:39:36 +03:00

922 lines
44 KiB
TypeScript

import Crop from "@boxicons/js/icons/Crop";
import { readFileSync } from "fs";
import { join } from "path";
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fakeBrowser } from "wxt/testing/fake-browser";
import { iconVariant, Popup, previewDocument, shortcutsByCommand, textToHtml } from "./main";
const DISCONNECTED = "This action can't be performed without active connection to Trilium.";
const UNREACHABLE = "This action is not available on this page.";
const sendMessage = vi.fn(async (_message: object): Promise<unknown> => undefined);
const openOptionsPage = vi.fn(async () => {});
const getAllCommands = vi.fn(async () => [
{ name: "saveSelection", shortcut: "Ctrl+Shift+S" },
{ name: "saveWholePage", shortcut: "Alt+Shift+S" },
{ name: "saveCroppedScreenshot", shortcut: "Ctrl+Shift+E" },
{ name: "saveTabs", shortcut: "" }
]);
const PAGE = {
title: "An article",
content: `<p>Body</p><img src="i1"><img src="https://example.com/b.png">`,
images: [ { imageId: "i1", src: "https://example.com/a.png" } ],
pageUrl: "https://example.com/post?id=1",
clipType: "page",
labels: { pageAuthor: "Jane Doe", publishedDate: "2024-05-01", modifiedDate: "2024-06-12" }
};
const WHOLE_PAGE = { ...PAGE, content: `<nav>Menu</nav><p>Body</p><img src="i1">` };
/** How `readable-page.js` reads the tab, by variant, as `browser.scripting.executeScript()` answers. */
let pageReadings: Record<string, unknown> = {};
const executeScript = vi.fn(async ({ args }: { args?: [ string ] }): Promise<unknown> =>
args ? [ { result: pageReadings[args[0]] } ] : []);
const tabsQuery = vi.fn(async (): Promise<{ id?: number, title?: string, url?: string }[]> => [ { id: 7 } ]);
const SELECTION = {
title: "Page title",
content: `<p>A quote</p><img src="i2">`,
images: [ { imageId: "i2", src: "https://example.com/c.png" } ],
pageUrl: "https://example.com/post?id=1#part"
};
/** What the content script answers to `trilium-save-selection`; nothing is selected unless a test says so. */
let selection: unknown;
/** What the content script answers to `trilium-get-page-metadata`, or the error it fails with; no description unless a test says so. */
let metadata: unknown;
const tabsSendMessage = vi.fn(async (_tabId: number, message: { name: string }): Promise<unknown> => {
if (message.name === "trilium-get-page-metadata") {
if (metadata instanceof Error) throw metadata;
return metadata;
}
return selection;
});
const closeWindow = vi.fn();
let container: HTMLElement;
describe("popup", () => {
beforeEach(async () => {
fakeBrowser.reset();
selection = undefined;
metadata = {};
pageReadings = { article: PAGE, wholePage: WHOLE_PAGE };
Object.assign(fakeBrowser.runtime, { sendMessage, openOptionsPage });
Object.assign(fakeBrowser.commands, { getAll: getAllCommands });
Object.assign(fakeBrowser.tabs, { query: tabsQuery, sendMessage: tabsSendMessage });
Object.assign(fakeBrowser, { scripting: { executeScript } });
vi.spyOn(window, "close").mockImplementation(closeWindow);
vi.spyOn(console, "log").mockImplementation(() => {});
container = document.createElement("div");
document.body.appendChild(container);
await act(() => render(<Popup />, container));
await flush();
});
afterEach(async () => {
await act(() => render(null, container));
container.remove();
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("asks for the connection status and shows it in the footer", async () => {
expect(sendMessage).toHaveBeenCalledWith({ name: "send-trilium-search-status" });
expect(status()).toEqual({ dot: "status-dot status-dot-pending", text: "Looking for Trilium…" });
expect(captureButtons()).toHaveLength(4);
expect(container.querySelector(".popup-header")).toBeNull();
expect([ ...container.querySelectorAll(".connection button") ].map((b) => b.getAttribute("aria-label")))
.toEqual([ "Check the connection again", "Options", "Help" ]);
expect(captureButtons().every((button) => !button.disabled)).toBe(true);
await receive({ name: "trilium-search-status", triliumSearch: { status: "found-desktop", port: 37840, url: "" } });
expect(status()).toEqual({ dot: "status-dot status-dot-ok", text: "Connected to the desktop app" });
expect(container.querySelector<HTMLElement>(".connection .status-ok")?.title).toBe("Connected to port 37840");
expect(container.querySelector(".connection .status-ok")).not.toBeNull();
expect(captureButtons().every((button) => !button.disabled)).toBe(true);
expect(button("Save page to Trilium")?.title).toBe("");
expect(button("Crop screenshot")?.title).toBe("Crop screenshot (Ctrl+Shift+E)");
expect(sendMessage).toHaveBeenCalledWith({ name: "trigger-trilium-search-note-url" });
await receive({
name: "trilium-search-status",
triliumSearch: { status: "found-server", url: "https://trilium.example", token: "t" }
});
expect(status().text).toBe("Connected to the server");
expect(container.querySelector<HTMLElement>(".connection .status-ok")?.title)
.toBe("Connected to https://trilium.example");
await receive({ name: "trilium-search-status", triliumSearch: { status: "searching" } });
expect(status()).toEqual({ dot: "status-dot status-dot-pending", text: "Looking for Trilium…" });
for (const button of captureButtons()) {
expect(button.disabled).toBe(true);
expect(button.title).toBe(DISCONNECTED);
}
await receive({ name: "unrelated" });
expect(status().text).toBe("Looking for Trilium…");
});
it("explains a version mismatch above the actions, which stay enabled", async () => {
expect(container.querySelector(".callout-warning")).toBeNull();
await receive({
name: "trilium-search-status",
triliumSearch: { status: "version-mismatch", extensionMajor: 2, triliumMajor: 1 }
});
expect(status()).toEqual({ dot: "status-dot status-dot-warning", text: "Incompatible version" });
expect(container.querySelector(".popup > .notices:first-child > .callout-warning")?.textContent)
.toContain("Please update Trilium Notes");
expect(captureButtons().every((button) => !button.disabled)).toBe(true);
await receive({
name: "trilium-search-status",
triliumSearch: { status: "version-mismatch", extensionMajor: 1, triliumMajor: 2 }
});
expect(container.querySelector(".callout-warning")?.textContent).toContain("Please update this extension");
});
it("replaces the actions with a way out when Trilium is not found", async () => {
await receive({ name: "trilium-search-status", triliumSearch: { status: "not-found" } });
expect(status()).toEqual({ dot: "status-dot status-dot-error", text: "Not found" });
expect(captureButtons()).toHaveLength(0);
const notFound = container.querySelector(".no-items.not-found");
expect(notFound).not.toBeNull();
expect(notFound?.querySelector(":scope > svg.icon path")).not.toBeNull();
expect(notFound?.querySelector("h4")?.textContent).toBe("Trilium was not found");
expect(notFound?.querySelector("p")?.textContent)
.toBe("Start the desktop app, or connect to a server in the options.");
expect([ ...notFound?.querySelectorAll("button") ?? [] ].map((b) => [ b.textContent, !!b.querySelector("svg.icon") ]))
.toEqual([ [ "Retry", true ], [ "Open options", true ] ]);
expect(sendMessage).not.toHaveBeenCalledWith({ name: "trigger-trilium-search-note-url" });
await click("Retry");
expect(sendMessage).toHaveBeenCalledWith({ name: "trigger-trilium-search" });
await click("Open options");
expect(openOptionsPage).toHaveBeenCalledOnce();
expect(closeWindow).not.toHaveBeenCalled();
});
it("offers to open a page that was already clipped", async () => {
expect(container.querySelector(".already-visited")).toBeNull();
await receive({ name: "trilium-previously-visited", searchNote: { status: "found", noteId: "clipped" } });
expect(container.querySelector(".popup > .notices:first-child > .already-visited span")?.textContent)
.toBe("Web page already clipped.");
const link = container.querySelector<HTMLAnchorElement>(".already-visited a");
expect(link?.textContent).toBe("Open in Trilium");
await act(() => link?.click());
expect(sendMessage).toHaveBeenCalledWith({ name: "openNoteInTrilium", noteId: "clipped" });
await receive({ name: "trilium-previously-visited", searchNote: { status: "not-found", noteId: null } });
expect(container.querySelector(".already-visited")).toBeNull();
expect(container.querySelector(".notices")).toBeNull();
});
it("sends the capture actions, closing the popup for screenshots", async () => {
await click("Crop screenshot");
expect(sendMessage).toHaveBeenCalledWith({ name: "save-cropped-screenshot" });
await click("Visible area screenshot");
expect(sendMessage).toHaveBeenCalledWith({ name: "save-whole-screenshot" });
expect(closeWindow).toHaveBeenCalledTimes(2);
await click("All tabs in window");
expect(sendMessage).toHaveBeenCalledWith({ name: "save-tabs" });
await click("Check the connection again");
expect(sendMessage).toHaveBeenCalledWith({ name: "trigger-trilium-search" });
await click("Options");
expect(openOptionsPage).toHaveBeenCalledOnce();
expect(closeWindow).toHaveBeenCalledTimes(2);
const openWindow = vi.spyOn(window, "open").mockImplementation(() => null);
await click("Help");
expect(openWindow).toHaveBeenCalledWith("https://docs.triliumnotes.org/user-guide/setup/web-clipper", "_blank");
});
it("shows the keyboard shortcut of each action that has one", async () => {
expect(getAllCommands).toHaveBeenCalledOnce();
expect(shortcutOf("Save page to Trilium")).toEqual({ text: "Alt+Shift+S", keys: [ "Alt", "Shift", "S" ] });
expect(toolbar()).toEqual([
{ label: "Crop", title: "Crop screenshot (Ctrl+Shift+E)" },
{ label: "Screenshot", title: "Visible area screenshot" },
{ label: "Tabs", title: "All tabs in window" }
]);
expect(container.querySelectorAll(".toolbar-action kbd")).toHaveLength(0);
getAllCommands.mockResolvedValueOnce([ { name: "saveWholePage", shortcut: "⌥⇧S" } ]);
await rerender();
expect(shortcutOf("Save page to Trilium")).toEqual({ text: "⌥⇧S", keys: [ "⌥⇧S" ] });
});
it("previews the readable page with its title, source and date", () => {
expect(tabsQuery).toHaveBeenCalledWith({ active: true, currentWindow: true });
expect(executeScript).toHaveBeenCalledWith({ target: { tabId: 7 }, files: [ "/readable-page.js" ] });
expect(cardParts()).toEqual([ "page-heading", "clip-mode", "page-body", "btn btn-primary primary-action" ]);
expect(modes()).toEqual([ [ "Page", "true" ], [ "Bookmark", "false" ] ]);
const heading = container.querySelector(".page-heading");
expect(heading).not.toBeNull();
expect(heading?.querySelector(".page-icon svg path")).not.toBeNull();
expect(heading?.querySelector<HTMLInputElement>(".page-title")?.value).toBe("An article");
expect(heading?.querySelector(".page-meta")?.textContent).toBe("example.com");
expect(publishedDate()).toBe("Published 2024-05-01");
const dateIcon = container.querySelector<HTMLElement>(".page-fields > .page-field:nth-child(3) > .page-field-icon");
expect(dateIcon?.querySelector("svg.icon path")).not.toBeNull();
expect(dateIcon?.title).toBe(
"The date the page was published. It is saved in the #publishedDate label of the note.");
expect(modifiedDate()).toBe("Updated 2024-06-12");
const modifiedIcon = container.querySelector<HTMLElement>(".page-fields > .page-field:nth-child(4) > .page-field-icon");
expect(modifiedIcon?.querySelector("svg.icon path")).not.toBeNull();
expect(modifiedIcon?.title).toBe(
"The date the page was last modified. It is saved in the #modifiedDate label of the note.");
const frame = container.querySelector<HTMLIFrameElement>(".page-body iframe.page-content");
expect(frame?.getAttribute("sandbox")).toBe("allow-same-origin");
expect(frame?.getAttribute("srcdoc")).toBe(previewDocument(PAGE));
expect([ ...container.querySelector(".page-body")?.children ?? [] ].map((part) => part.className))
.toEqual([ "page-fields", "page-variant", "page-content" ]);
expect(button("Save page to Trilium")?.disabled).toBe(false);
});
it("reads the whole page when the user switches to it, once, and keeps the choice", async () => {
expect(variants()).toEqual([ [ "Article", "true", false ], [ "Whole page", "false", false ] ]);
expect(container.querySelector(".page-variant")?.getAttribute("aria-label")).toBe("How to read the page");
expect(wholePageReads()).toBe(0);
let finishReading: () => void = () => {};
executeScript
.mockResolvedValueOnce([])
.mockReturnValueOnce(new Promise((resolve) => {
finishReading = () => resolve([ { result: WHOLE_PAGE } ]);
}));
await click("Whole page");
expect(variants()).toEqual([ [ "Article", "false", false ], [ "Whole page", "true", false ] ]);
expect(placeholder()).toBe("Reading the page…");
expect(button("Save page to Trilium")?.disabled).toBe(true);
await act(() => finishReading());
await flush();
expect(frameDocument()).toBe(previewDocument(WHOLE_PAGE));
await click("Article");
expect(frameDocument()).toBe(previewDocument(PAGE));
await click("Whole page");
expect(frameDocument()).toBe(previewDocument(WHOLE_PAGE));
expect(wholePageReads()).toBe(1);
await click("Bookmark");
expect(container.querySelector(".page-variant")).toBeNull();
await receive({ name: "trilium-search-status", triliumSearch: { status: "not-found" } });
await receive({ name: "trilium-search-status", triliumSearch: { status: "found-desktop", port: 37840, url: "" } });
await flush();
await click("Page");
await flush();
expect(variants()).toEqual([ [ "Article", "false", false ], [ "Whole page", "true", false ] ]);
await type(".page-title", "Gallery");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-whole-page", page: { ...WHOLE_PAGE, title: "Gallery" } });
await click("Article");
expect(frameDocument()).toBe(previewDocument(PAGE));
});
it("goes back to the article when the whole page shows nothing or cannot be read", async () => {
pageReadings = { article: PAGE };
await click("Whole page");
await flush();
expect(variants()).toEqual([ [ "Article", "true", false ], [ "Whole page", "false", true ] ]);
expect(button("Whole page")?.title).toBe("Trilium found nothing to save on this page.");
expect(frameDocument()).toBe(previewDocument(PAGE));
await rerender();
executeScript.mockRejectedValueOnce(new Error("The tab was closed."));
await click("Whole page");
await flush();
expect(variants()).toEqual([ [ "Article", "true", false ], [ "Whole page", "false", true ] ]);
});
it("opens a page without an article on the whole page", async () => {
pageReadings = { wholePage: WHOLE_PAGE };
await rerender();
expect(modes()).toEqual([ [ "Page", "true" ], [ "Bookmark", "false" ] ]);
expect(variants()).toEqual([ [ "Article", "false", true ], [ "Whole page", "true", false ] ]);
expect(button("Article")?.title).toBe("Trilium found no article on this page.");
expect(frameDocument()).toBe(previewDocument(WHOLE_PAGE));
expect(container.querySelector<HTMLInputElement>(".page-title")?.value).toBe("An article");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-whole-page", page: WHOLE_PAGE });
});
it("saves the extracted page with the edited title, and closes", async () => {
await type(".page-title", " My title ");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenCalledWith({ name: "save-whole-page", page: { ...PAGE, title: "My title" } });
expect(closeWindow).toHaveBeenCalledOnce();
await type(".page-title", " ");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-whole-page", page: PAGE });
});
it("leaves out the dates of a page that states none", async () => {
pageReadings = { article: { ...PAGE, labels: {} }, wholePage: WHOLE_PAGE };
await rerender();
expect(container.querySelector(".page-meta")?.textContent).toBe("example.com");
expect(container.querySelectorAll(".page-fields > .page-field")).toHaveLength(2);
expect(publishedDate()).toBeUndefined();
expect(modifiedDate()).toBeUndefined();
});
it("says so while the page is read", async () => {
let resolvePage: (page: unknown) => void = () => {};
executeScript.mockReturnValueOnce(new Promise((resolve) => {
resolvePage = resolve;
}));
await rerender();
expect(placeholder()).toBe("Reading the page…");
expect(cardParts()).toEqual([ "page-body", "btn btn-primary primary-action" ]);
expect(button("Save page to Trilium")?.disabled).toBe(true);
resolvePage([]);
await flush();
expect(container.querySelector(".page-body iframe")).not.toBeNull();
});
it("opens a page with nothing to save on a bookmark, and keeps every other action", async () => {
pageReadings = {};
await rerender();
expect(container.querySelector(".no-items")).toBeNull();
expect(container.querySelector(".clip-mode")).toBeNull();
expect(container.querySelector(".clip-hint")?.textContent).toBe("This page has nothing to save; save it as a bookmark.");
expect(document.activeElement).toBe(container.querySelector(".page-body .note-text"));
expect(container.querySelector(".page-meta")).toBeNull();
expect(button("Save bookmark")?.disabled).toBe(false);
expect(captureButtons().map((action) => action.disabled)).toEqual([ false, false, false, false ]);
});
it("keeps only the actions that need no access to a page out of reach", async () => {
executeScript.mockRejectedValueOnce(new Error("Cannot access a chrome:// URL"));
tabsSendMessage.mockRejectedValueOnce(new Error("Could not establish connection."));
await rerender();
expect(unavailable()).toBe(
"The extension cannot access this page.\nIf it is a regular web page, reload it."
);
expect(container.querySelector(".page-preview")).toBeNull();
expect(captureButtons().map((action) => action.disabled)).toEqual([ true, true, false ]);
expect(button("Crop screenshot")?.title).toBe(UNREACHABLE);
expect(button("Visible area screenshot")?.title).toBe(UNREACHABLE);
tabsQuery.mockResolvedValueOnce([ {} ]);
await rerender();
expect(unavailable()).toContain("The extension cannot access this page.");
expect(executeScript).toHaveBeenCalledTimes(3);
expect(tabsSendMessage).toHaveBeenCalledTimes(4);
});
it("previews the selection first, and switches to the whole page", async () => {
selection = SELECTION;
await rerender();
expect(tabsSendMessage).toHaveBeenCalledWith(7, { name: "trilium-save-selection" });
expect(modes()).toEqual([ [ "Selection", "true" ], [ "Page", "false" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(SELECTION));
expect(shortcutOf("Save selection").keys).toEqual([ "Ctrl", "Shift", "S" ]);
expect(container.querySelector(".page-meta")?.textContent).toBe("example.com");
expect(publishedDate()).toBeUndefined();
expect(modifiedDate()).toBeUndefined();
await click("Page");
expect(modes()).toEqual([ [ "Selection", "false" ], [ "Page", "true" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(PAGE));
expect(publishedDate()).toBe("Published 2024-05-01");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-whole-page", page: PAGE });
await click("Selection");
await type(".page-title", " Quote ");
await click("Save selection");
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-selection", selection: { ...SELECTION, title: "Quote" } });
expect(closeWindow).toHaveBeenCalledTimes(2);
});
it("saves the selection of a page with nothing else to save, and ignores an empty one", async () => {
selection = SELECTION;
pageReadings = {};
await rerender();
expect(container.querySelector(".no-items")).toBeNull();
expect(modes()).toEqual([ [ "Selection", "true" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(SELECTION));
expect(container.querySelector<HTMLInputElement>(".page-title")?.value).toBe("Page title");
await click("Save selection");
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-selection", selection: SELECTION });
selection = { ...SELECTION, content: " <p> </p>", images: [] };
pageReadings = { article: PAGE, wholePage: WHOLE_PAGE };
await rerender();
expect(modes()).toEqual([ [ "Page", "true" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(PAGE));
selection = { ...SELECTION, content: `<img src="i2">` };
await rerender();
expect(modes()).toHaveLength(3);
expect(frameDocument()).toBe(previewDocument(selection as typeof SELECTION));
});
it("tells the user when the background script cannot be reached", async () => {
const alertMock = vi.fn();
vi.stubGlobal("alert", alertMock);
sendMessage.mockRejectedValueOnce(new Error("Receiving end does not exist."));
await click("All tabs in window");
expect(alertMock).toHaveBeenCalledWith("Calling browser runtime failed. Refreshing page might help.");
});
it("writes a bookmark in the card, keeping the note across modes", async () => {
tabsQuery.mockResolvedValueOnce([ { id: 7, title: "A page", url: "https://example.com/post" } ]);
await rerender();
expect(container.querySelector(".note-text")).toBeNull();
await click("Bookmark");
expect(modes()).toEqual([ [ "Page", "false" ], [ "Bookmark", "true" ] ]);
const textArea = container.querySelector(".page-body .note-text");
expect(textArea).not.toBeNull();
expect(document.activeElement).toBe(textArea);
expect(textArea?.getAttribute("placeholder")).toBe("Add a note about this page");
expect(container.querySelector(".page-body iframe")).toBeNull();
expect(container.querySelector<HTMLInputElement>(".page-title")?.value).toBe("An article");
expect(container.querySelector(".page-meta")?.textContent).toBe("example.com");
expect(publishedDate()).toBeUndefined();
expect(modifiedDate()).toBeUndefined();
expect(shortcutOf("Save bookmark")).toEqual({ text: "Ctrl+Enter", keys: [ "Ctrl", "Enter" ] });
await type(".page-body .note-text", "Worth a read");
await click("Page");
expect(container.querySelector(".note-text")).toBeNull();
await click("Bookmark");
expect(container.querySelector<HTMLTextAreaElement>(".page-body .note-text")?.value).toBe("Worth a read");
expect(closeWindow).not.toHaveBeenCalled();
sendMessage.mockResolvedValueOnce(true);
await click("Save bookmark");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-link-with-note",
title: "An article",
content: "<p>Worth a read</p>"
});
expect(closeWindow).toHaveBeenCalledOnce();
});
it("shows the page's description atop the preview in every mode, and saves it as edited", async () => {
metadata = { description: "All about the post" };
selection = SELECTION;
await rerender();
const description = () =>
container.querySelector<HTMLTextAreaElement>(".page-body > .page-fields .page-description");
expect(description()?.value).toBe("All about the post");
expect(description()?.getAttribute("aria-label")).toBe("Page description");
expect(description()?.getAttribute("placeholder")).toBe("Add a description");
const icon = container.querySelector<HTMLElement>(".page-fields > .page-field:first-child > .page-field-icon");
expect(icon?.querySelector("svg.icon path")).not.toBeNull();
expect(icon?.title).toBe(
"The page's description. It is saved in the #pageDescription label of the note.");
await click("Save selection");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-selection",
selection: { ...SELECTION, title: "An article", labels: { pageDescription: "All about the post" } }
});
await click("Page");
await type(".page-description", " Edited\n description ");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-whole-page",
page: { ...PAGE, labels: { ...PAGE.labels, pageDescription: "Edited description" } }
});
await click("Bookmark");
expect(description()?.value).toBe(" Edited\n description ");
sendMessage.mockResolvedValueOnce(undefined);
await ctrlEnter(".page-description");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-link-with-note",
title: "An article",
content: "",
description: "Edited description"
});
// A cleared description stays in the card while it is edited, and is not saved.
await type(".page-description", " ");
expect(description()).not.toBeNull();
await click("Save bookmark");
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-link-with-note", title: "An article", content: "" });
await click("Page");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-whole-page",
page: PAGE
});
});
it("offers an empty description where the page gives none or cannot be asked for one", async () => {
const description = () => container.querySelector<HTMLTextAreaElement>(".page-description");
// An outdated content script answers nothing.
metadata = undefined;
await rerender();
expect(description()?.value).toBe("");
await type(".page-description", "Written by hand");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-whole-page",
page: { ...PAGE, labels: { ...PAGE.labels, pageDescription: "Written by hand" } }
});
metadata = new Error("Could not establish connection.");
await rerender();
await click("Bookmark");
expect(description()?.value).toBe("");
expect(container.querySelector(".page-body .note-text")).not.toBeNull();
});
it("offers the page's author in Page mode, and saves it as edited", async () => {
const author = () => container.querySelector<HTMLInputElement>(".page-fields .page-author");
expect(author()?.value).toBe("Jane Doe");
expect(author()?.getAttribute("aria-label")).toBe("Author");
expect(author()?.getAttribute("placeholder")).toBe("Add an author");
const icon = container.querySelector<HTMLElement>(".page-fields > .page-field:nth-child(2) > .page-field-icon");
expect(icon?.querySelector("svg.icon path")).not.toBeNull();
expect(icon?.title).toBe("The page's author. It is saved in the #pageAuthor label of the note.");
await type(".page-author", " John Smith ");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-whole-page",
page: { ...PAGE, labels: { ...PAGE.labels, pageAuthor: "John Smith" } }
});
await type(".page-author", " ");
expect(author()).not.toBeNull();
await click("Save page to Trilium");
const { pageAuthor: _, ...withoutAuthor } = PAGE.labels;
expect(sendMessage).toHaveBeenLastCalledWith({ name: "save-whole-page", page: { ...PAGE, labels: withoutAuthor } });
selection = SELECTION;
await rerender();
expect(author()).toBeNull();
await click("Bookmark");
expect(author()).toBeNull();
selection = undefined;
pageReadings = { article: { ...PAGE, labels: {} }, wholePage: WHOLE_PAGE };
await rerender();
expect(author()?.value).toBe("");
await type(".page-author", "Written by hand");
await click("Save page to Trilium");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-whole-page",
page: { ...PAGE, labels: { pageAuthor: "Written by hand" } }
});
});
it("never lets a preview run scripts, since it shares the extension's origin", async () => {
selection = SELECTION;
await rerender();
for (const mode of [ "Selection", "Page" ]) {
await click(mode);
const frame = container.querySelector(".page-body iframe.page-content");
expect(frame, mode).not.toBeNull();
const sandbox = frame?.getAttribute("sandbox")?.split(/\s+/);
expect(sandbox, mode).toContain("allow-same-origin");
expect(sandbox, mode).not.toContain("allow-scripts");
}
});
it("keeps the preview as tall as the page, so that it scrolls with the description", async () => {
const frame = container.querySelector<HTMLIFrameElement>(".page-body iframe.page-content");
expect(frame).not.toBeNull();
if (!frame) return;
let height = 640;
const root = { getBoundingClientRect: () => ({ height }) };
let onResize: (() => void) | undefined;
const observe = vi.fn();
class ResizeObserver {
constructor(callback: () => void) { onResize = callback; }
observe = observe;
}
Object.defineProperty(frame, "contentDocument", { configurable: true, value: { documentElement: root } });
Object.defineProperty(frame, "contentWindow", { configurable: true, value: { ResizeObserver } });
await act(() => {
frame.dispatchEvent(new Event("load"));
});
expect(observe).toHaveBeenCalledWith(root);
onResize?.();
expect(frame.style.height).toBe("640px");
// The page grows when it rewraps beside the card's scrollbar, or when an image loads.
height = 712.4;
onResize?.();
expect(frame.style.height).toBe("713px");
Object.defineProperty(frame, "contentDocument", { configurable: true, value: null });
await act(() => {
frame.dispatchEvent(new Event("load"));
});
expect(frame.style.height).toBe("713px");
});
it("keeps a draft bookmark while Trilium is briefly not found", async () => {
await click("Bookmark");
await type(".page-title", "My title");
await type(".page-body .note-text", "Half-written note");
await receive({ name: "trilium-search-status", triliumSearch: { status: "not-found" } });
expect(container.querySelector(".page-body .note-text")).toBeNull();
await receive({ name: "trilium-search-status", triliumSearch: { status: "found-desktop", port: 37840, url: "" } });
await flush();
expect(modes()).toEqual([ [ "Page", "false" ], [ "Bookmark", "true" ] ]);
expect(container.querySelector<HTMLInputElement>(".page-title")?.value).toBe("My title");
expect(container.querySelector<HTMLTextAreaElement>(".page-body .note-text")?.value).toBe("Half-written note");
});
it("saves the title and the text as they are, on Ctrl+Enter from either field", async () => {
await click("Bookmark");
await type(".page-title", " Read later. Soon ");
await type(".page-body .note-text", " It has <b>tips</b>\nand more ");
sendMessage.mockResolvedValueOnce(true);
await ctrlEnter(".page-body .note-text");
expect(sendMessage).toHaveBeenCalledWith({
name: "save-link-with-note",
title: "Read later. Soon",
content: "<p>It has &lt;b&gt;tips&lt;/b&gt;</p><p>and more</p>"
});
expect(closeWindow).toHaveBeenCalledOnce();
});
it("keeps the bookmark open when it could not be saved, and leaves an empty title to the page", async () => {
await click("Bookmark");
await type(".page-title", "");
const title = container.querySelector(".page-title");
await act(async () => {
title?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ name: "save-link-with-note" }));
sendMessage.mockResolvedValueOnce(undefined);
await ctrlEnter(".page-title");
expect(sendMessage).toHaveBeenCalledWith({ name: "save-link-with-note", title: "", content: "" });
expect(container.querySelector(".page-body .note-text")).not.toBeNull();
expect(closeWindow).not.toHaveBeenCalled();
const calls = sendMessage.mock.calls.length;
await click("Page");
await ctrlEnter(".page-title");
expect(sendMessage).toHaveBeenCalledTimes(calls);
});
it("renders itself into the page's root element", async () => {
const root = document.createElement("div");
root.id = "root";
document.body.appendChild(root);
vi.resetModules();
await import("./main");
expect(root.querySelector(".popup")).not.toBeNull();
root.remove();
});
});
describe("previewDocument", () => {
it("loads the page's images from the website, and keeps the rest of its content", () => {
const doc = new DOMParser().parseFromString(previewDocument(PAGE), "text/html");
expect([ ...doc.querySelectorAll("img") ].map((img) => img.getAttribute("src")))
.toEqual([ "https://example.com/a.png", "https://example.com/b.png" ]);
expect(doc.querySelector("p")?.textContent).toBe("Body");
expect(doc.querySelector("style")?.textContent).toContain("max-width: 100%");
});
it("leaves the frame nothing to navigate to, by mouse, keyboard or refresh", () => {
const doc = new DOMParser().parseFromString(previewDocument({
content: `<p>See <a href="https://example.com/next">the next page</a></p>`
+ `<meta http-equiv="refresh" content="0; url=https://example.com/elsewhere">`
+ `<map><area href="https://example.com/area" alt="Area"></map>`,
images: []
}), "text/html");
const link = doc.querySelector("p a");
expect(link?.textContent).toBe("the next page");
expect(link?.hasAttribute("href")).toBe(false);
expect(doc.querySelector("area")?.hasAttribute("href")).toBe(false);
expect(doc.querySelector("meta[http-equiv]")).toBeNull();
});
it("shows an embedded YouTube video as a chip, and drops other embedded content", () => {
const doc = new DOMParser().parseFromString(previewDocument({
content: `<p>Intro</p>`
+ `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1"></iframe>`
+ `<iframe src="https://www.youtube-nocookie.com/embed/jNQXAC9IVRw"></iframe>`
+ `<iframe src="https://player.vimeo.com/video/76979871"></iframe>`
+ `<iframe></iframe>`,
images: []
}), "text/html");
expect(doc.querySelector("iframe")).toBeNull();
expect(doc.querySelector("p")?.textContent).toBe("Intro");
const chips = [ ...doc.querySelectorAll(".embed-chip") ];
expect(chips.map((chip) => [ ...chip.children ].map((child) => child.textContent))).toEqual([
[ "", "YouTube video", "youtu.be/dQw4w9WgXcQ" ],
[ "", "YouTube video", "youtu.be/jNQXAC9IVRw" ]
]);
expect(chips[0]?.querySelector("svg path")).not.toBeNull();
});
it("colors links with the theme's link colors, which the frame cannot read from the popup", () => {
const theme = readFileSync(join(import.meta.dirname, "../../assets/theme.css"), "utf8");
const [ light, dark ] = [ ...theme.matchAll(/--link-color: (#[0-9a-f]+);/g) ].map((match) => match[1]);
expect(light).toBeDefined();
expect(dark).toBeDefined();
expect(previewDocument(PAGE)).toContain(`color: light-dark(${light}, ${dark})`);
});
});
describe("shortcutsByCommand", () => {
it("keeps only the commands that have a shortcut", () => {
expect(shortcutsByCommand([
{ name: "saveWholePage", shortcut: "Alt+Shift+S" },
{ name: "saveTabs", shortcut: "" },
{ name: "saveSelection" },
{ shortcut: "Ctrl+Q" }
])).toEqual({ saveWholePage: "Alt+Shift+S" });
});
});
describe("icons", () => {
it("draws each button's Boxicon inline, hidden from assistive technology", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
Object.assign(fakeBrowser.commands, { getAll: getAllCommands });
Object.assign(fakeBrowser.tabs, { query: tabsQuery, sendMessage: tabsSendMessage });
Object.assign(fakeBrowser, { scripting: { executeScript } });
const container = document.createElement("div");
await act(() => render(<Popup />, container));
const icons = container.querySelectorAll("button > svg.icon");
expect(icons).toHaveLength(7);
for (const icon of icons) {
expect(icon.getAttribute("aria-hidden")).toBe("true");
expect(icon.getAttribute("viewBox")).toBe("0 0 24 24");
expect(icon.querySelector("path")).not.toBeNull();
}
for (const button of container.querySelectorAll("button.icon-action")) {
expect(button.getAttribute("aria-label")).toBe(button.getAttribute("title"));
}
await act(() => render(null, container));
});
it("uses the outlined variant, and refuses an icon that has none", () => {
expect(iconVariant(Crop)).toBe(Crop.packs.basic);
expect(() => iconVariant({ name: "brand", defaultPack: "brands", packs: {} }))
.toThrow("Boxicons has no basic variant of 'brand'.");
});
});
describe("textToHtml", () => {
it("escapes the text and makes a paragraph of each line", () => {
expect(textToHtml("a & <b>\nc")).toBe("<p>a &amp; &lt;b&gt;</p><p>c</p>");
expect(textToHtml("")).toBe("<p></p>");
});
});
async function receive(message: object) {
await act(async () => {
await fakeBrowser.runtime.onMessage.trigger(message, {}, () => {});
});
}
/** Lets the promises the effects start, such as reading the shortcuts, settle. */
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve));
});
}
/** The button with the given label, or with that `aria-label` when it shows only an icon. */
function button(text: string) {
const found = [ ...container.querySelectorAll("button") ].find((b) => {
const label = b.querySelector(".action-label")?.textContent ?? b.textContent?.trim();
return label === text || b.getAttribute("aria-label") === text;
});
expect(found, text).toBeDefined();
return found;
}
/** Renders the popup again, so that it extracts the page anew. */
async function rerender() {
await act(() => render(null, container));
await act(() => render(<Popup />, container));
await flush();
}
async function type(selector: string, value: string) {
const input = container.querySelector<HTMLInputElement>(selector);
expect(input, selector).not.toBeNull();
await act(() => {
if (!input) return;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function unavailable() {
const empty = container.querySelector(".capture-actions > .no-items");
expect(empty).not.toBeNull();
expect(empty?.querySelector("svg.icon path")).not.toBeNull();
return empty?.textContent;
}
async function ctrlEnter(selector: string) {
const field = container.querySelector(selector);
expect(field, selector).not.toBeNull();
await act(async () => {
field?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }));
});
}
function placeholder() {
return container.querySelector(".page-body > .page-preview-placeholder")?.textContent;
}
async function click(text: string) {
const target = button(text);
await act(() => target?.click());
}
/** The shortcut shown on a button: its text, and the keys drawn as separate key caps. */
function shortcutOf(text: string) {
const shortcut = button(text)?.querySelector(".shortcut");
expect(shortcut, text).not.toBeNull();
return {
text: shortcut?.textContent,
keys: [ ...shortcut?.querySelectorAll(":scope > kbd") ?? [] ].map((key) => key.textContent)
};
}
function toolbar() {
return [ ...container.querySelectorAll<HTMLButtonElement>(".toolbar-action") ].map((action) => {
expect(action.getAttribute("aria-label")).toBe(action.title.replace(/ \(.*\)$/, ""));
return { label: action.querySelector(".action-label")?.textContent, title: action.title };
});
}
/** The clip mode switch's options, each with whether it is the current one. */
function modes() {
return [ ...container.querySelectorAll(".clip-mode button") ]
.map((option) => [ option.textContent, option.getAttribute("aria-pressed") ]);
}
/** How many times the popup read the whole page. */
function wholePageReads() {
return executeScript.mock.calls.filter(([ injection ]) => injection.args?.[0] === "wholePage").length;
}
/** The page variant switch's options, each with whether it is the current one and whether it is disabled. */
function variants() {
return [ ...container.querySelectorAll<HTMLButtonElement>(".page-body > .page-variant button") ]
.map((option) => [ option.textContent, option.getAttribute("aria-pressed"), option.disabled ]);
}
function frameDocument() {
const frame = container.querySelector(".page-body iframe.page-content");
expect(frame).not.toBeNull();
return frame?.getAttribute("srcdoc");
}
/** The class names of the preview card's parts, in order. */
function cardParts() {
const card = container.querySelector(".page-preview");
expect(card).not.toBeNull();
return [ ...card?.children ?? [] ].map((part) => part.className);
}
function captureButtons() {
return [ ...container.querySelectorAll<HTMLButtonElement>(".capture-actions button") ]
.filter((button) => !button.closest(".clip-mode, .page-variant"));
}
function status() {
return {
dot: container.querySelector<HTMLElement>(".connection > span > .status-dot")?.className,
text: container.querySelector(".connection > span")?.textContent
};
}
function publishedDate() {
return container.querySelector(".page-fields .page-published-date")?.textContent;
}
function modifiedDate() {
return container.querySelector(".page-fields .page-modified-date")?.textContent;
}