diff --git a/tests/e2e/coverage-ui-runtime.spec.ts b/tests/e2e/coverage-ui-runtime.spec.ts new file mode 100644 index 00000000..3b274cc6 --- /dev/null +++ b/tests/e2e/coverage-ui-runtime.spec.ts @@ -0,0 +1,305 @@ +import { expect, test } from "./helpers/instrumentedTest"; + +test.describe("runtime UI utility coverage", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/docs/kitchen-sink.html?__test__=1", { + waitUntil: "networkidle", + }); + }); + + test("exercises dynamic style scope registration and lifecycle", async ({ page }) => { + const result = await page.evaluate(async () => { + const { + registerScope, + setVariables, + releaseScope, + getScopeAttributeName, + } = await import("/js/designSystem/dynamicStyles.js"); + + const firstScope = registerScope("coverage-scope", [":scope", "& .title", "> .child"]); + const secondScope = registerScope("coverage-scope", [":scope"]); + + const firstUpdate = setVariables(firstScope, { + "--card-gap": "12px", + "--muted": "", + invalid: "skip", + }); + const secondUpdate = setVariables(firstScope, { + "--card-gap": 8, + }); + + const missingScopeUpdate = setVariables("missing-scope", { "--x": "1" }); + const releaseMissing = releaseScope("missing-scope"); + const releaseFirst = releaseScope(firstScope); + const releaseSecond = releaseScope(secondScope); + + return { + firstScope, + secondScope, + firstUpdate, + secondUpdate, + missingScopeUpdate, + releaseMissing, + releaseFirst, + releaseSecond, + scopeAttr: getScopeAttributeName(), + styleTagCount: document.querySelectorAll("style[data-ds-dynamic='true']").length, + adoptedSheetCount: Array.isArray((document as any).adoptedStyleSheets) + ? (document as any).adoptedStyleSheets.length + : 0, + }; + }); + + expect(result.firstScope).toBe("coverage-scope"); + expect(result.secondScope).toBe("coverage-scope-1"); + expect(result.firstUpdate).toBe(true); + expect(result.secondUpdate).toBe(true); + expect(result.missingScopeUpdate).toBe(false); + expect(result.releaseMissing).toBe(false); + expect(result.releaseFirst).toBe(true); + expect(result.releaseSecond).toBe(true); + expect(result.scopeAttr).toBe("data-ds-style-id"); + expect(result.styleTagCount + result.adoptedSheetCount).toBeGreaterThanOrEqual(1); + }); + + test("exercises tag preference menu rendering, actions, and state transitions", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + createTagPreferenceMenu, + applyTagPreferenceMenuState, + TAG_PREFERENCE_ACTIONS, + } = await import("/js/ui/components/tagPreferenceMenu.js"); + + const actionEvents: Array<{ action: string; tag: string }> = []; + + const loggedOut = createTagPreferenceMenu({ + tag: "#BitVid", + isLoggedIn: false, + }); + document.body.appendChild(loggedOut!.panel); + + const loggedIn = createTagPreferenceMenu({ + tag: " nostr ", + isLoggedIn: true, + membership: { state: "interest" }, + onAction: (action: string, detail: any) => { + actionEvents.push({ action, tag: detail.normalizedTag }); + }, + }); + document.body.appendChild(loggedIn!.panel); + + loggedIn!.buttons.removeInterest.click(); + loggedIn!.buttons.addDisinterest.click(); + + applyTagPreferenceMenuState({ + buttons: loggedIn!.buttons, + membership: { state: "disinterest", interest: true, disinterest: true }, + isLoggedIn: true, + }); + + return { + actions: TAG_PREFERENCE_ACTIONS, + loggedOutTag: loggedOut!.panel.dataset.tag, + loggedOutMessage: loggedOut!.panel.textContent, + loggedOutDisabled: { + addInterest: loggedOut!.buttons.addInterest.disabled, + removeInterest: loggedOut!.buttons.removeInterest.disabled, + addDisinterest: loggedOut!.buttons.addDisinterest.disabled, + removeDisinterest: loggedOut!.buttons.removeDisinterest.disabled, + }, + loggedInTag: loggedIn!.panel.dataset.tag, + loggedInDisabledAfterTransition: { + addInterest: loggedIn!.buttons.addInterest.disabled, + removeInterest: loggedIn!.buttons.removeInterest.disabled, + addDisinterest: loggedIn!.buttons.addDisinterest.disabled, + removeDisinterest: loggedIn!.buttons.removeDisinterest.disabled, + }, + actionEvents, + }; + }); + + expect(result.actions.ADD_INTEREST).toBe("add-interest"); + expect(result.loggedOutTag).toBe("bitvid"); + expect(result.loggedInTag).toBe("nostr"); + expect(result.loggedOutMessage).toContain("Sign in to personalize"); + expect(result.loggedOutDisabled).toEqual({ + addInterest: true, + removeInterest: true, + addDisinterest: true, + removeDisinterest: true, + }); + + expect(result.actionEvents.map((entry: any) => entry.action)).toEqual([ + "remove-interest", + "add-disinterest", + ]); + expect(result.actionEvents.every((entry: any) => entry.tag === "nostr")).toBe(true); + expect(result.loggedInDisabledAfterTransition).toEqual({ + addInterest: false, + removeInterest: true, + addDisinterest: true, + removeDisinterest: false, + }); + }); + + test("exercises docs view initialization, hash routing, drawer toggle, and error handling", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + document.body.innerHTML = ` + + +
Second doc
Advanced'; + } + return 'const x = 1;Intro';
+ },
+ };
+
+ const highlighted: string[] = [];
+ (window as any).hljs = {
+ highlightElement(el: Element) {
+ highlighted.push(el.textContent || "");
+ },
+ };
+
+ const realFetch = window.fetch.bind(window);
+ const fetchLog: string[] = [];
+ window.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ fetchLog.push(url);
+ if (url.endsWith("content/docs/toc.json")) {
+ return new Response(
+ JSON.stringify({
+ items: [
+ {
+ slug: "intro",
+ title: "Intro",
+ path: "/content/docs/intro.md",
+ children: [
+ {
+ slug: "intro-nested",
+ title: "Nested",
+ path: "/content/docs/nested.md",
+ },
+ ],
+ },
+ {
+ slug: "advanced",
+ title: "Advanced",
+ path: "/content/docs/advanced.md",
+ },
+ ],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ if (url.endsWith("/content/docs/intro.md")) {
+ return new Response("INTRO_DOC", { status: 200 });
+ }
+ if (url.endsWith("/content/docs/advanced.md")) {
+ return new Response("SECOND_DOC", { status: 200 });
+ }
+ return realFetch(input);
+ }) as typeof fetch;
+
+ const { initDocsView } = await import("/js/docsView.js");
+ await initDocsView();
+
+ const initialTitle = document.title;
+ const initialHash = window.location.hash;
+ const topLevelLinks = Array.from(document.querySelectorAll("#docsTocList [data-docs-toc-item]")).map(
+ (el) => (el as HTMLElement).dataset.slug,
+ );
+ const drawerLinks = Array.from(document.querySelectorAll("#docsTocDrawerNav [data-docs-toc-item]")).map(
+ (el) => (el as HTMLElement).dataset.slug,
+ );
+
+ const toggle = document.querySelector("[data-docs-toc-toggle]") as HTMLButtonElement;
+ const drawer = document.getElementById("docsTocDrawer") as HTMLElement;
+ const backdrop = drawer.querySelector(".bv-modal-backdrop") as HTMLElement;
+
+ toggle.click();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const drawerOpen = drawer.getAttribute("data-open");
+ const toggleExpandedOpen = toggle.getAttribute("aria-expanded");
+
+ backdrop.click();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const drawerClosed = drawer.getAttribute("data-open");
+
+ window.location.hash = "#view=docs&doc=advanced";
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const advancedTitle = document.title;
+ const markdownHtml =
+ document.getElementById("markdown-container")?.innerHTML || "";
+
+ window.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("content/docs/toc.json")) {
+ return new Response("broken", { status: 500 });
+ }
+ return realFetch(input);
+ }) as typeof fetch;
+
+ document.getElementById("docsTocList")!.innerHTML = "";
+ document.getElementById("markdown-container")!.innerHTML = "";
+
+ await initDocsView();
+ const tocErrorText = document.getElementById("docsTocList")?.textContent || "";
+ const markdownErrorText =
+ document.getElementById("markdown-container")?.textContent || "";
+
+ window.fetch = realFetch;
+
+ return {
+ initialTitle,
+ initialHash,
+ advancedTitle,
+ markdownHtml,
+ topLevelLinks,
+ drawerLinks,
+ drawerOpen,
+ drawerClosed,
+ toggleExpandedOpen,
+ fetchLog,
+ highlighted,
+ tocErrorText,
+ markdownErrorText,
+ };
+ });
+
+ expect(result.initialTitle).toBe("Intro | bitvid docs");
+ expect(result.initialHash).toContain("view=docs&doc=intro");
+ expect(result.advancedTitle).toBe("Advanced | bitvid docs");
+ expect(result.markdownHtml).toContain("Second doc");
+ expect(result.topLevelLinks).toContain("intro");
+ expect(result.topLevelLinks).toContain("advanced");
+ expect(result.drawerLinks).toContain("intro");
+ expect(result.drawerOpen).toBe("true");
+ expect(result.drawerClosed).toBe("false");
+ expect(result.toggleExpandedOpen).toBe("true");
+ expect(result.fetchLog.some((url: string) => url.endsWith("content/docs/toc.json"))).toBe(true);
+ expect(result.fetchLog.some((url: string) => url.endsWith("/content/docs/intro.md"))).toBe(true);
+ expect(result.fetchLog.some((url: string) => url.endsWith("/content/docs/advanced.md"))).toBe(true);
+ expect(result.highlighted.length).toBeGreaterThan(0);
+ expect(result.tocErrorText).toContain("Unable to load docs table of contents");
+ expect(result.markdownErrorText).toContain("Error loading content");
+ });
+});
diff --git a/tests/e2e/coverage-utility-modules.spec.ts b/tests/e2e/coverage-utility-modules.spec.ts
new file mode 100644
index 00000000..9ca3081f
--- /dev/null
+++ b/tests/e2e/coverage-utility-modules.spec.ts
@@ -0,0 +1,219 @@
+import { expect, test } from "./helpers/instrumentedTest";
+
+test.describe("runtime utility module coverage", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/docs/kitchen-sink.html?__test__=1", {
+ waitUntil: "networkidle",
+ });
+ });
+
+ test("exercises search filter parsing and serialization", async ({ page }) => {
+ const result = await page.evaluate(async () => {
+ const { parseFilterQuery, serializeFiltersToQuery } = await import(
+ "/js/search/searchFilters.js"
+ );
+
+ const parsed = parseFilterQuery(
+ 'author:abc123 tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm',
+ );
+
+ const withErrors = parseFilterQuery(
+ "duration:oops has:bad unknown:value before:not-a-date",
+ );
+
+ const serialized = serializeFiltersToQuery(parsed.filters);
+
+ return {
+ parsed,
+ withErrors,
+ serialized,
+ };
+ });
+
+ expect(result.parsed.filters.authorPubkeys).toEqual(["abc123"]);
+ expect(result.parsed.filters.tags).toEqual(["music", "nostr"]);
+ expect(result.parsed.filters.kind).toBe(30078);
+ expect(result.parsed.filters.relay).toBe("wss://relay.example");
+ expect(result.parsed.filters.duration.minSeconds).toBe(90);
+ expect(result.parsed.filters.hasMagnet).toBe(true);
+ expect(result.parsed.filters.nsfw).toBe("false");
+ expect(result.parsed.text).toContain("exact phrase");
+ expect(result.parsed.text).toContain("looseTerm");
+
+ expect(result.withErrors.errors.length).toBeGreaterThanOrEqual(3);
+ expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true);
+ expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true);
+ expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true);
+
+ expect(result.serialized).toContain("author:abc123");
+ expect(result.serialized).toContain("tag:music");
+ expect(result.serialized).toContain("kind:30078");
+ expect(result.serialized).toContain("has:magnet");
+ });
+
+ test("exercises video note payload normalization and error mapping", async ({ page }) => {
+ const result = await page.evaluate(async () => {
+ const {
+ normalizeVideoNotePayload,
+ VIDEO_NOTE_ERROR_CODES,
+ getVideoNoteErrorMessage,
+ } = await import("/js/services/videoNotePayload.js");
+
+ const invalid = normalizeVideoNotePayload({
+ title: "",
+ url: "http://insecure.example/video.mp4",
+ });
+
+ const normalized = normalizeVideoNotePayload({
+ legacyFormData: {
+ title: " Coverage Video ",
+ url: "https://cdn.example/video.mp4 ",
+ magnet:
+ "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
+ ws: "https://webseed.example/video.mp4",
+ xs: "https://cdn.example/video.torrent",
+ mode: "DEV",
+ isPrivate: true,
+ isNsfw: true,
+ isForKids: true,
+ storageProvider: "r2",
+ fileSha256:
+ "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD",
+ originalFileSha256:
+ "00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF",
+ },
+ nip71: {
+ summary: " summary text ",
+ publishedAt: "2024-05-01T12:00:00Z",
+ duration: "120",
+ hashtags: ["nostr", " video "],
+ references: [" https://example.com/ref "],
+ textTracks: [
+ {
+ url: " https://example.com/captions.vtt ",
+ type: " subtitles ",
+ language: " en ",
+ },
+ ],
+ segments: [{ start: "0", end: "30", title: " Intro " }],
+ participants: [
+ {
+ pubkey:
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ relay: " wss://relay.example ",
+ },
+ ],
+ imeta: [
+ {
+ m: " VIDEO/MP4 ",
+ url: "https://cdn.example/video.mp4",
+ image: [" https://cdn.example/thumb.jpg "],
+ duration: "98",
+ },
+ ],
+ },
+ });
+
+ return {
+ invalid,
+ normalized,
+ invalidUrlCode: VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL,
+ invalidUrlMessage: getVideoNoteErrorMessage(
+ VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL,
+ ),
+ fallbackMessage: getVideoNoteErrorMessage("unknown"),
+ };
+ });
+
+ expect(result.invalid.errors).toContain(result.invalidUrlCode);
+ expect(result.invalid.errors).toContain("missing_title");
+ expect(result.invalidUrlMessage).toMatch(/HTTPS/i);
+ expect(result.fallbackMessage).toMatch(/Unable to submit video/i);
+
+ const legacy = result.normalized.payload.legacyFormData;
+ expect(legacy.title).toBe("Coverage Video");
+ expect(legacy.mode).toBe("dev");
+ expect(legacy.isPrivate).toBe(true);
+ expect(legacy.isNsfw).toBe(true);
+ expect(legacy.isForKids).toBe(false);
+ expect(legacy.storagePointer).toContain("r2:");
+ expect(legacy.fileSha256).toHaveLength(64);
+ expect(legacy.originalFileSha256).toHaveLength(64);
+ expect(legacy.magnet).toContain("magnet:?xt=urn:btih:");
+ expect(legacy.ws).toContain("https://webseed.example/video.mp4");
+ expect(legacy.xs).toContain("https://cdn.example/video.torrent");
+
+ const nip71 = result.normalized.payload.nip71;
+ expect(nip71.summary).toBe("summary text");
+ expect(nip71.duration).toBe(120);
+ expect(nip71.imeta[0].m).toBe("video/mp4");
+ expect(nip71.hashtags).toEqual(["nostr", "video"]);
+ expect(nip71.segments[0].start).toBe(0);
+ expect(nip71.participants[0].relay).toBe("wss://relay.example");
+ });
+
+ test("exercises attachment tag parsing and attachment descriptions", async ({
+ page,
+ }) => {
+ const result = await page.evaluate(async () => {
+ const {
+ ATTACHMENT_KIND,
+ parseAttachmentTags,
+ extractAttachmentsFromMessage,
+ formatAttachmentSize,
+ describeAttachment,
+ } = await import("/js/attachments/attachmentUtils.js");
+
+ const parsed = parseAttachmentTags([
+ ["x", "A".repeat(64)],
+ ["url", "https://cdn.example/file.bin"],
+ ["name", "manual.pdf"],
+ ["type", "application/pdf"],
+ ["size", "2048"],
+ ["k", "secret"],
+ ]);
+
+ const noSource = parseAttachmentTags([["name", "nameless"]]);
+ const extracted = extractAttachmentsFromMessage({
+ message: {
+ kind: ATTACHMENT_KIND,
+ tags: [["x", "b".repeat(64)], ["url", "https://cdn.example/second.bin"]],
+ },
+ });
+ const skipped = extractAttachmentsFromMessage({
+ event: { kind: 1, tags: [["x", "c".repeat(64)]] },
+ });
+
+ return {
+ parsed,
+ noSource,
+ extractedLength: extracted.length,
+ extractedHash: extracted[0]?.x,
+ skippedLength: skipped.length,
+ sizeBytes: formatAttachmentSize(999),
+ sizeKilobytes: formatAttachmentSize(2048),
+ sizeMegabytes: formatAttachmentSize(1048576),
+ describedName: describeAttachment(parsed),
+ describedType: describeAttachment({ type: "image/png" }),
+ describedFallback: describeAttachment(null),
+ };
+ });
+
+ expect(result.parsed.x).toBe("a".repeat(64));
+ expect(result.parsed.url).toBe("https://cdn.example/file.bin");
+ expect(result.parsed.size).toBe(2048);
+ expect(result.parsed.encrypted).toBe(true);
+ expect(result.noSource).toBeNull();
+
+ expect(result.extractedLength).toBe(1);
+ expect(result.extractedHash).toBe("b".repeat(64));
+ expect(result.skippedLength).toBe(0);
+
+ expect(result.sizeBytes).toBe("999 B");
+ expect(result.sizeKilobytes).toBe("2.0 KB");
+ expect(result.sizeMegabytes).toBe("1.0 MB");
+ expect(result.describedName).toBe("Attachment: manual.pdf");
+ expect(result.describedType).toBe("Attachment (image/png)");
+ expect(result.describedFallback).toBe("Attachment");
+ });
+});