diff --git a/src/commands.test.ts b/src/commands.test.ts index 80447c0f..2ec9a78e 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -108,6 +108,7 @@ function fakeWorkspace(notes: ReturnType["notes"]) { return {}; }), getLeavesOfType: (type: string) => (type === "markdown" ? [...open] : []), + iterateAllLeaves: vi.fn(), getActiveViewOfType: () => null, getRightLeaf: () => null, revealLeaf: vi.fn(), diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index 414451da..0d3315c7 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -291,7 +291,10 @@ describe("PodNotes onload wiring (#55)", () => { vi.restoreAllMocks(); }); - async function loadPlugin(loadedData: Record = {}) { + async function loadPlugin( + loadedData: Record = {}, + own: Partial> = {}, + ) { const activateSpy = vi .spyOn(PodNotes.prototype, "activateView") .mockResolvedValue(undefined); @@ -350,12 +353,14 @@ describe("PodNotes onload wiring (#55)", () => { on: vi.fn(() => ({})), offref: vi.fn(), getLeavesOfType: vi.fn(() => []), + iterateAllLeaves: vi.fn(), getRightLeaf: vi.fn(() => null), getActiveViewOfType: vi.fn(() => null), revealLeaf: vi.fn(), detachLeavesOfType: vi.fn(), }, }, + ...own, }); await plugin.onload(); @@ -364,6 +369,33 @@ describe("PodNotes onload wiring (#55)", () => { return { commands, ribbonCalls, activateSpy, plugin }; } + it("removes an earlier build's sheets before it loads anything, and keeps those its views append", async () => { + const sheet = (id: string) => + document.head.appendChild(Object.assign(document.createElement("style"), { id })); + const shown = () => [...document.head.querySelectorAll("style")].map((style) => style.id); + sheet("pn-earlier"); + const seen: Record = {}; + onTestFinished(() => document.head.replaceChildren()); + + await loadPlugin( + { schemaVersion: 2 }, + { + loadData: vi.fn(async () => { + seen.loadData = shown(); + return { schemaVersion: 2 }; + }), + // Obsidian mounts the restored views here, and each appends its sheet. + registerView: vi.fn(() => { + seen.registerView = shown(); + sheet("pn-mounted"); + }), + }, + ); + + expect(seen).toEqual({ loadData: [], registerView: [] }); + expect(shown()).toEqual(["pn-mounted"]); + }); + it("starts no save once the window fires pagehide", async () => { const { plugin } = await loadPlugin({ schemaVersion: 2 }); const domEvents = vi.mocked(plugin.registerDomEvent).mock.calls as unknown as Array< diff --git a/src/main.fileRenames.test.ts b/src/main.fileRenames.test.ts index 6e22b894..fc593d1b 100644 --- a/src/main.fileRenames.test.ts +++ b/src/main.fileRenames.test.ts @@ -103,6 +103,7 @@ async function loadPlugin(data: Record): Promise { onLayoutReady: vi.fn(), on: vi.fn(() => ({})), getLeavesOfType: vi.fn(() => []), + iterateAllLeaves: vi.fn(), getRightLeaf: vi.fn(() => null), getActiveViewOfType: vi.fn(() => null), revealLeaf: vi.fn(), diff --git a/src/main.ts b/src/main.ts index 60155412..3340433e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab"; import { MainView } from "src/ui/PodcastView"; import { Navigation } from "src/ui/PodcastView/Navigation"; import { dismissUndoNotices } from "src/ui/undoNotice"; +import { removePreviousStyles } from "src/ui/removePreviousStyles"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; import type { IPodNotes } from "./types/IPodNotes"; import type PartialAppExtension from "./global"; @@ -127,6 +128,8 @@ export default class PodNotes extends Plugin implements IPodNotes { private persistenceUnknownFields: Record = {}; override async onload() { + // Before registerView, where the views Obsidian restores mount and append their sheets. + removePreviousStyles(this.app.workspace); this.isUnloaded = false; this.podcastViewMountEnabled = !this.isMobileRuntime(); this.registerDomEvent(window, "pagehide", () => { diff --git a/src/main.undo.test.ts b/src/main.undo.test.ts index 9e276e27..19537b6e 100644 --- a/src/main.undo.test.ts +++ b/src/main.undo.test.ts @@ -70,6 +70,7 @@ async function loadPlugin(secrets: obsidian.SecretStorage): Promise { on: vi.fn(() => ({})), offref: vi.fn(), getLeavesOfType: vi.fn(() => []), + iterateAllLeaves: vi.fn(), getActiveViewOfType: vi.fn(() => null), }, }, diff --git a/src/ui/PodcastView/AddPodcastPage.svelte b/src/ui/PodcastView/AddPodcastPage.svelte index ccd26f6a..57789fbb 100644 --- a/src/ui/PodcastView/AddPodcastPage.svelte +++ b/src/ui/PodcastView/AddPodcastPage.svelte @@ -43,12 +43,12 @@ {#snippet alternatives()}
- {:else} @@ -114,7 +112,7 @@ {#if current >= 0}
@@ -117,7 +113,6 @@ type="button" class="pn-iconbtn pn-ep-more" aria-label="More options for {episode.title}" - title="More options" aria-haspopup="menu" onclick={menu} oncontextmenu={menu} @@ -127,29 +122,33 @@ diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index bb058c48..395d9840 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -95,7 +95,6 @@ class="pn-iconbtn" aria-pressed={$hidePlayedEpisodes} aria-label={hideLabel} - title={hideLabel} onclick={() => hidePlayedEpisodes.update((hidden) => !hidden)} {@attach icon("eye-off")} > @@ -103,7 +102,6 @@ type="button" class="pn-iconbtn" aria-label="Refresh episodes" - title="Refresh episodes" disabled={!$online} onclick={refresh} {@attach icon("refresh-cw")} @@ -122,14 +120,12 @@ {/if} diff --git a/src/ui/PodcastView/EpisodesPage.test.ts b/src/ui/PodcastView/EpisodesPage.test.ts index 0bced951..537c179f 100644 --- a/src/ui/PodcastView/EpisodesPage.test.ts +++ b/src/ui/PodcastView/EpisodesPage.test.ts @@ -219,9 +219,9 @@ describe("a podcast's page", () => { }; expect(within(downloaded).getByRole("button", { name: "Rerun, downloaded" })).toBeVisible(); - expect(downloaded.querySelector('[title="Downloaded"]')).not.toBeNull(); + expect(downloaded.querySelector('[data-icon="arrow-down-to-line"]')).not.toBeNull(); expect(within(streamed).getByRole("button", { name: "Rerun" })).toBeVisible(); - expect(streamed.querySelector('[title="Downloaded"]')).toBeNull(); + expect(streamed.querySelector('[data-icon="arrow-down-to-line"]')).toBeNull(); expect(await menuOf(downloaded)).toContain("Remove download"); const streamedMenu = await menuOf(streamed); expect(streamedMenu).toContain("Download"); diff --git a/src/ui/PodcastView/LibraryPage.svelte b/src/ui/PodcastView/LibraryPage.svelte index 18a9430d..1c72631c 100644 --- a/src/ui/PodcastView/LibraryPage.svelte +++ b/src/ui/PodcastView/LibraryPage.svelte @@ -25,7 +25,7 @@ > {root.label} - {#if counts[root.kind]}{counts[root.kind]}{/if} + {#if counts[root.kind]}{counts[root.kind]}{/if} {/each} @@ -37,9 +37,8 @@ {@const failed = feedState !== undefined && shownFailure(feedState, $online) !== undefined} @@ -95,7 +80,6 @@ type="button" class="pn-iconbtn pn-mini-toggle" aria-label={playing ? "Pause" : "Play"} - title={playing ? "Pause" : "Play"} onclick={() => player.toggle()} {@attach icon(playing ? "pause" : "play")} > @@ -104,7 +88,7 @@ diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte index 04fa34eb..7e7cd733 100644 --- a/src/ui/PodcastView/Panel.svelte +++ b/src/ui/PodcastView/Panel.svelte @@ -1,3 +1,32 @@ + + + + + (isFullscreen = !!videoFrame && viewRoot.doc.fullscreenElement === videoFrame)} +/> + {#snippet panelView(kind: PanelKind)} {#if kind === "chapters"} @@ -159,7 +167,7 @@ - {:else if episode.artworkUrl && episode.artworkUrl !== failedArtworkUrl} - (failedArtworkUrl = episode.artworkUrl ?? "")} + title={episode.podcastName} + tile={tileOf(episode)} /> - {:else} - {/if} diff --git a/src/ui/PodcastView/Player.test.ts b/src/ui/PodcastView/Player.test.ts index 2c831f35..9dd7bd12 100644 --- a/src/ui/PodcastView/Player.test.ts +++ b/src/ui/PodcastView/Player.test.ts @@ -10,6 +10,7 @@ import type { Episode } from "src/types/Episode"; import { isLocalFile } from "src/utility/isLocalFile"; import type { FakeDocument } from "../../../tests/mocks/fakeMedia"; import type { Keymap as FakeKeymap, Menu as FakeMenu } from "../../../tests/mocks/obsidian"; +import { currentRowTop, layOut } from "../../../tests/mocks/panelLayout"; import { openPopout } from "../../../tests/mocks/popout"; import { installResizeObserver, @@ -94,18 +95,20 @@ const press = (key: string, target: Element = document.activeElement ?? document const shownMenu = () => (obsidian.Menu as unknown as typeof FakeMenu).shown!; describe("the full player", () => { - test("shows the artwork, and a file tile for a local file or artwork that fails to load (#394)", async () => { + test("shows the artwork, and its podcast's name as rows do when the artwork fails to load (#394)", async () => { const { player } = await openPlayer(); - const art = player.querySelector("img.pn-np-art")!; + const art = player.querySelector(".pn-stage img")!; expect(art).toHaveAttribute("src", episode.artworkUrl); await fireEvent.error(art); expect(player.querySelector("img")).toBeNull(); - expect(player.querySelector(".pn-np-art.pn-filetile")).toHaveAttribute( - "data-icon", - "music", - ); + expect(player.querySelector(".pn-stage")).toHaveTextContent(episode.podcastName); + + services.player.play({ ...queued("No artwork"), artworkUrl: undefined }); + await tick(); + expect(player.querySelector(".pn-stage [data-icon]"), "no tile").toBeNull(); + expect(player.querySelector(".pn-stage")).toHaveTextContent(episode.podcastName); }); test("a local file shows a file tile, and its folder links to Local files", async () => { @@ -118,7 +121,7 @@ describe("the full player", () => { const { player } = await openPlayer(local); expect(player.querySelector("img")).toBeNull(); - expect(player.querySelector(".pn-np-art.pn-filetile")).toHaveAttribute( + expect(player.querySelector(".pn-stage > [data-icon]")).toHaveAttribute( "data-icon", "music", ); @@ -338,6 +341,70 @@ describe("the full player", () => { ).toBeInTheDocument(); }); + test("in fullscreen, an Escape only leaves it, and the frame's button leaves it too", async () => { + const { player } = await openPlayer({ ...episode, mediaType: "video" }); + const frame = player.querySelector(".pn-video")!; + let fullscreenElement: Element | null = null; + const change = (to: Element | null) => { + fullscreenElement = to; + document.dispatchEvent(new Event("fullscreenchange")); + }; + const exit = vi.fn(async () => change(null)); + Object.defineProperties(document, { + fullscreenElement: { configurable: true, get: () => fullscreenElement }, + exitFullscreen: { configurable: true, value: exit }, + }); + onTestFinished(() => { + for (const key of ["fullscreenElement", "exitFullscreen"]) + delete (document as unknown as Record)[key]; + }); + frame.requestFullscreen = vi.fn(async () => change(frame)); + const expanded = () => services.navigation.current.expanded; + + await fireEvent.click(button("Fullscreen")); + expect(button("Exit fullscreen")).toBeInTheDocument(); + press("Escape"); + await tick(); + expect(exit, "the first Escape leaves fullscreen").toHaveBeenCalledOnce(); + expect(expanded(), "and leaves the player open").toBe(true); + + await fireEvent.click(button("Fullscreen")); + await fireEvent.click(button("Exit fullscreen")); + expect(exit).toHaveBeenCalledTimes(2); + expect(button("Fullscreen")).toBeInTheDocument(); + press("Escape"); + await tick(); + expect(expanded(), "an Escape after fullscreen").toBe(false); + }); + + test("an Escape closes the player after the next episode, audio, took fullscreen's frame away", async () => { + const { player } = await openPlayer({ ...episode, mediaType: "video" }); + let fullscreenElement: Element | null = null; + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => fullscreenElement, + }); + onTestFinished(() => { + delete (document as unknown as Record).fullscreenElement; + }); + fullscreenElement = player.querySelector(".pn-video"); + document.dispatchEvent(new Event("fullscreenchange")); + + services.player.play({ + ...episode, + title: "Audio next", + streamUrl: "https://pod.test/next.mp3", + }); + await tick(); + fullscreenElement = null; + document.dispatchEvent(new Event("fullscreenchange")); + await tick(); + press("Escape"); + await tick(); + + expect(services.navigation.current.expanded).toBe(false); + }); + test("the video frame plays and pauses on its own, as it does in fullscreen", async () => { const { player } = await openPlayer({ ...episode, mediaType: "video" }); const frame = within(player.querySelector(".pn-video")!); @@ -360,7 +427,7 @@ describe("the full player", () => { await tick(); expect(player.querySelector(".pn-video")).toBeNull(); - expect(player.querySelector("img.pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage img")).not.toBeNull(); }); }); @@ -371,15 +438,15 @@ describe("the player's panels", () => { test("at compact width, a panel replaces the artwork until it is chosen again", async () => { const { player } = await openPlayer(withChapters); await vi.waitFor(() => expect(switchButton("Chapters")).toBeInTheDocument()); - expect(player.querySelector(".pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).not.toBeNull(); await fireEvent.click(switchButton("Up next")); expect(switchButton("Up next")).toHaveAttribute("aria-pressed", "true"); - expect(player.querySelector(".pn-np-art")).toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).toBeNull(); expect(within(player).getByText("Queue is empty")).toBeInTheDocument(); await fireEvent.click(switchButton("Up next")); - expect(player.querySelector(".pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).not.toBeNull(); }); test("an episode without chapters offers its Transcript and Up next, no Chapters", async () => { @@ -492,7 +559,7 @@ describe("the player's panels", () => { await vi.waitFor(() => expect(switchButton("Chapters")).toBeInTheDocument()); expect(switchButton("Chapters")).toHaveAttribute("aria-pressed", "false"); - expect(player.querySelector(".pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).not.toBeNull(); }); test("the current chapter is named under the scrubber, opens Chapters, and is marked there", async () => { @@ -525,6 +592,24 @@ describe("the player's panels", () => { expect(get(services.player.state)).toMatchObject({ currentTime: 0, status: "playing" }); }); + test("Chapters keeps a long chapter playing now above the panel's faded bottom edge", async () => { + await openPlayer(withChapters); + await vi.waitFor(() => expect(switchButton("Chapters")).toBeInTheDocument()); + await fireEvent.click(switchButton("Chapters")); + const list = screen.getByRole("list", { name: "Chapters" }); + layOut(list, { height: 160, tall: { 0: 80, 1: 100 } }); + const playAt = async (time: number) => { + doc.media.currentTime = time; + doc.media.emit("timeupdate"); + await tick(); + }; + + await playAt(0); + await playAt(650); + + expect(currentRowTop(list), "its 100 px end where the 20 px fade starts").toBe(40); + }); + describe("Up next", () => { const [one, two, three] = ["One", "Two", "Three"].map(queued); diff --git a/src/ui/PodcastView/PlaylistsPage.svelte b/src/ui/PodcastView/PlaylistsPage.svelte index 835df9aa..0e2f87ee 100644 --- a/src/ui/PodcastView/PlaylistsPage.svelte +++ b/src/ui/PodcastView/PlaylistsPage.svelte @@ -20,7 +20,7 @@ {#snippet row(playlist: Playlist, tile: IconType, favorite: boolean)} @@ -50,24 +54,20 @@ .pn-playlists :global { .pn-plrow { display: flex; - align-items: center; gap: var(--size-4-3); width: 100%; height: auto; padding: var(--size-4-2); - border: 0; border-radius: var(--radius-s); - background: none; - box-shadow: none; color: var(--text-normal); - font-size: var(--font-ui-small); font-weight: inherit; text-align: left; - cursor: var(--cursor); } - .pn-plrow:hover { - background: var(--background-modifier-hover); + @media (hover: hover) { + .pn-plrow:hover { + background: var(--background-modifier-hover); + } } .pn-plicon { diff --git a/src/ui/PodcastView/PodcastView.shell.test.ts b/src/ui/PodcastView/PodcastView.shell.test.ts index fd27a4fb..aea8dfb4 100644 --- a/src/ui/PodcastView/PodcastView.shell.test.ts +++ b/src/ui/PodcastView/PodcastView.shell.test.ts @@ -9,6 +9,7 @@ import type { ParsedFeedEpisodes } from "src/parser/feedDocumentParser"; import { FeedRepository } from "src/services/FeedRepository"; import { favorites, localFiles, playedEpisodes, playlists, queue, savedFeeds } from "src/store"; import type { Episode } from "src/types/Episode"; +import type { LocalEpisode } from "src/types/LocalEpisode"; import type { Playlist } from "src/types/Playlist"; import { installResizeObserver, @@ -169,7 +170,7 @@ describe("library", () => { name, episodes, }); - const flair = (name: RegExp) => button(name).querySelector(".flair")?.textContent; + const flair = (name: RegExp) => button(name).querySelector(".pn-flair")?.textContent; test("is the one the view's services hold", async () => { const { library } = services; @@ -280,7 +281,7 @@ describe("list pages", () => { expect(screen.getByRole("heading", { name: "Walks" }).nextElementSibling).toHaveTextContent( "1", ); - expect(document.querySelectorAll(".flair")).toHaveLength(1); + expect(document.querySelectorAll(".pn-flair")).toHaveLength(1); }); test("a playlist of the user's deletes from its head and leaves for Playlists; Favorites has no menu", async () => { @@ -564,6 +565,29 @@ describe("mini player", () => { expect(button("Play")).toBeInTheDocument(); }); + test("covers an episode without artwork as its rows do: its podcast's name, or a local file's tile", async () => { + renderView(320); + const cover = () => button(/^Open player/).querySelector(".pn-cover"); + + services.player.restore({ ...episode, artworkUrl: undefined }); + await tick(); + expect(cover()).toHaveTextContent(feed.title); + expect(cover()).not.toHaveAttribute("data-icon"); + + const talk: LocalEpisode = { + ...episode, + podcastName: "local file", + description: "", + content: "", + filePath: "Clips/Talk.mp4", + mediaType: "video", + }; + services.player.restore(talk); + await tick(); + expect(cover()).toHaveTextContent(""); + expect(cover()).toHaveAttribute("data-icon", "video"); + }); + test("Timestamp captures, compact or wide, and leaves saying where to the capture's notice", async () => { const notice = vi.spyOn(obsidian, "Notice"); services.player.restore(episode); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 95c2ebf4..4b408a84 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -97,7 +97,6 @@ type="button" class="pn-iconbtn pn-first-settings" aria-label="PodNotes settings" - title="PodNotes settings" onclick={openSettings} {@attach icon("settings")} > @@ -121,7 +120,6 @@ type="button" class={head.search ? "pn-iconbtn" : "pn-back"} aria-label="Back to {head.back}" - title={head.search && `Back to ${head.back}`} onclick={() => navigation.back()} > @@ -138,7 +136,7 @@ {:else} {#if head.title}

{head.title}

- {#if head.flair}{head.flair}{/if} + {#if head.flair}{head.flair}{/if} {/if} {/if} @@ -147,7 +145,6 @@ type="button" class="pn-iconbtn" aria-label="Add podcast" - title="Add podcast" onclick={() => navigation.go({ kind: "add" })} {@attach icon("plus")} > @@ -155,7 +152,6 @@ type="button" class="pn-iconbtn" aria-label="PodNotes settings" - title="PodNotes settings" onclick={openSettings} {@attach icon("settings")} > @@ -164,7 +160,6 @@ type="button" class="pn-iconbtn" aria-label="Refresh all feeds" - title="Refresh all feeds" disabled={!$online} onclick={() => feeds.load($feeds.keys(), "network")} {@attach icon("refresh-cw")} @@ -175,7 +170,6 @@ type="button" class="pn-iconbtn" aria-label="Podcast options" - title="Podcast options" aria-haspopup="menu" onclick={(event) => showMenu(podcastMenu(key, services), anchorOf(event))} @@ -186,7 +180,6 @@ type="button" class="pn-iconbtn" aria-label="Add a vault file" - title="Add a vault file" onclick={() => pickVaultMedia(services.app)} {@attach icon("plus")} > @@ -195,7 +188,6 @@ type="button" class="pn-iconbtn" aria-label="Queue options" - title="Queue options" aria-haspopup="menu" onclick={(event) => showMenu(queueMenu(services), anchorOf(event))} {@attach icon("more-horizontal")} @@ -206,7 +198,6 @@ type="button" class="pn-iconbtn" aria-label="Playlist options" - title="Playlist options" aria-haspopup="menu" onclick={(event) => showMenu(playlistMenu(name, services), anchorOf(event))} @@ -224,7 +215,7 @@ {#if head.pageTitle}

{head.pageTitle}

- {#if head.flair}{head.flair}{/if} + {#if head.flair}{head.flair}{/if}
{/if} {#if page.kind === "library"} @@ -254,25 +245,6 @@ diff --git a/src/ui/PodcastView/Rail.svelte b/src/ui/PodcastView/Rail.svelte index c0c21682..1b159c4e 100644 --- a/src/ui/PodcastView/Rail.svelte +++ b/src/ui/PodcastView/Rail.svelte @@ -26,7 +26,7 @@ > {root.label} - {#if counts[root.kind]}{counts[root.kind]}{/if} + {#if counts[root.kind]}{counts[root.kind]}{/if} {/each} @@ -40,7 +40,7 @@ diff --git a/src/ui/PodcastView/Scrubber.svelte b/src/ui/PodcastView/Scrubber.svelte index 24119dcb..48676f4c 100644 --- a/src/ui/PodcastView/Scrubber.svelte +++ b/src/ui/PodcastView/Scrubber.svelte @@ -94,27 +94,25 @@ diff --git a/src/ui/PodcastView/SearchField.svelte b/src/ui/PodcastView/SearchField.svelte index 07a35fa2..f0ccebda 100644 --- a/src/ui/PodcastView/SearchField.svelte +++ b/src/ui/PodcastView/SearchField.svelte @@ -54,7 +54,6 @@ type="button" class="pn-iconbtn pn-clear" aria-label="Clear" - title="Clear" onclick={() => { oninput(""); input.focus(); @@ -65,13 +64,11 @@ diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 494e5dbb..12716054 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -3,12 +3,19 @@ import type { Episode } from "src/types/Episode"; import { formatDuration } from "src/utility/formatDuration"; import { isLocalFile } from "src/utility/isLocalFile"; + import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { icon } from "../obsidian/icon"; import { useServices } from "./context"; - import Panel from "./Panel.svelte"; + import Panel, { reveal } from "./Panel.svelte"; import SearchField from "./SearchField.svelte"; - import { cachedTranscript, lineAt, loadTranscript, type Transcript } from "./transcript"; + import { + cachedTranscript, + foldCase, + lineAt, + loadTranscript, + type Transcript, + } from "./transcript"; type Load = | { kind: "loading" } @@ -26,7 +33,8 @@ position: number; onseek(time: number): void; } = $props(); - const { feeds, online, settings, saveTranscriptNote, transcribeFromAudio } = useServices(); + const { app, feeds, online, settings, rowHasNote, saveTranscriptNote, transcribeFromAudio } = + useServices(); let load = $state.raw({ kind: "loading" }); let list: HTMLElement | undefined = $state(); @@ -44,13 +52,21 @@ ? lineAt(load.transcript.lines, position) : -1, ); - const needle = $derived(finding ? query.toLowerCase() : ""); + const needle = $derived(finding ? foldCase(query) : ""); const shown = $derived( lines .map((line, index) => ({ line, index })) - .filter(({ line }) => !needle || line.text.toLowerCase().includes(needle)), + .filter(({ line }) => !needle || foldCase(line.text).includes(needle)), ); const audio = $derived(getEpisodeMediaType(episode) === "audio"); + // The note saved before, which the command would refuse to save again. A note made, moved or + // deleted since changes rowHasNote, and the transcript path is a setting. + const note = $derived.by(() => { + void $rowHasNote; + return app.vault.getFileByPath( + getEpisodeTranscriptPath(episode, $settings.transcript.path), + ); + }); const provider = $derived.by(() => { const { diarization } = $settings.transcript; return diarization?.enabled && diarization.provider === "deepgram" ? "Deepgram" : "OpenAI"; @@ -65,14 +81,19 @@ }); $effect(() => { - if (finding ? needle : following) reveal(finding ? hit : current); + if (list && (finding ? needle : following)) { + reveal(list, finding ? hit : current); + ownScrollTop = list.scrollTop; + } }); $effect(() => { const scroller = list; if (!scroller) return; + // A resize that lowers the list's last scroll position moves it without the reader. const scrolled = () => { - if (Math.abs(scroller.scrollTop - ownScrollTop) > 1) following = false; + const own = Math.min(ownScrollTop, scroller.scrollHeight - scroller.clientHeight); + if (Math.abs(scroller.scrollTop - own) > 1) following = false; }; scroller.addEventListener("scroll", scrolled); return () => scroller.removeEventListener("scroll", scrolled); @@ -98,15 +119,6 @@ ); } - /** Not scrollIntoView, which would also scroll the view and an editor around it. */ - function reveal(at: number) { - const row = list?.children[at]; - if (!list || !row) return; - const top = row.getBoundingClientRect().top - list.getBoundingClientRect().top; - list.scrollTop += top - list.clientHeight / 3; - ownScrollTop = list.scrollTop; - } - function seek(time: number) { following = true; onseek(time); @@ -130,7 +142,7 @@ function pieces(text: string): string[] { const found: string[] = []; - const lower = text.toLowerCase(); + const lower = foldCase(text); let from = 0; for (let at = lower.indexOf(needle); at >= 0; at = lower.indexOf(needle, from)) { found.push(text.slice(from, at), text.slice(at, at + needle.length)); @@ -149,21 +161,27 @@ > {#if load.kind === "loading"}
Loading transcript
- {:else if load.kind === "error"} - - {:else if load.kind === "none"} -
- + {:else if load.kind !== "ready"} + {@const failed = load.kind === "error"} +
{isLocalFile(episode) - ? "No transcript yet" - : "No transcript in this feed"} + {failed + ? "Transcript didn't load" + : isLocalFile(episode) + ? "No transcript yet" + : "No transcript in this feed"} - {#if audio} + {#if failed} + + {/if} + {#if note} + {@render saveNote()} + {:else if !failed && audio} @@ -247,32 +264,32 @@ type="button" class="pn-iconbtn" aria-label="Find in transcript" - title="Find in transcript" bind:this={findButton} onclick={() => (finding = true)} {@attach icon("search")} > - + {@render saveNote()} {/if} {/snippet} +{#snippet saveNote()} + +{/snippet} + diff --git a/src/ui/PodcastView/episodeRow.test.ts b/src/ui/PodcastView/episodeRow.test.ts index f172b294..c92d1243 100644 --- a/src/ui/PodcastView/episodeRow.test.ts +++ b/src/ui/PodcastView/episodeRow.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "vitest"; +import type { PlayerStatus } from "src/player/PlayerController"; import type { Episode } from "src/types/Episode"; import type { LocalEpisode } from "src/types/LocalEpisode"; import { availabilityOf, episodeRowModel, formatMinutes, + isPlaying, rowKeys, secondsLeft, totalSeconds, @@ -44,6 +46,21 @@ const record = (time: number, duration: number, finished = false) => ({ const row = (changes: Partial = {}, of: Episode = episode) => episodeRowModel(of, { ...state, ...changes }); +test("counts a loading episode as playing, and no other status", () => { + const playing: Record = { + empty: false, + restored: false, + loading: true, + playing: true, + paused: false, + ended: false, + error: false, + }; + + for (const [status, expected] of Object.entries(playing)) + expect(isPlaying(status as PlayerStatus), status).toBe(expected); +}); + describe("an episode row", () => { test("shows the date and the duration of an episode not yet played", () => { expect(row()).toMatchObject({ diff --git a/src/ui/PodcastView/episodeRow.ts b/src/ui/PodcastView/episodeRow.ts index da69eda5..18a75f34 100644 --- a/src/ui/PodcastView/episodeRow.ts +++ b/src/ui/PodcastView/episodeRow.ts @@ -1,3 +1,4 @@ +import type { PlayerStatus } from "src/player/PlayerController"; import { downloadedCopy, offlinePlayable } from "src/player/source"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import type { Episode } from "src/types/Episode"; @@ -7,6 +8,11 @@ import { getPlayedEpisode, type PlayedEpisodeMap } from "src/utility/episodeStat import { isLocalFile } from "src/utility/isLocalFile"; import { getEpisodeMediaType, isSameMediaSource } from "src/utility/mediaType"; +/** A loading episode plays once ready, so it already shows Pause. */ +export function isPlaying(status: PlayerStatus): boolean { + return status === "playing" || status === "loading"; +} + export type Downloads = Readonly>; /** @@ -117,6 +123,12 @@ function folderOf(path: string): string { return slash > 0 ? path.slice(0, slash) : "/"; } +/** A local file's tile, which stands in for the artwork it never has. */ +export function tileOf(episode: Episode): "music" | "video" | undefined { + if (!isLocalFile(episode)) return undefined; + return getEpisodeMediaType(episode) === "video" ? "video" : "music"; +} + /** Where the episode comes from: its podcast, or the folder of a local file. */ export function sourceOf(episode: Episode): string { return isLocalFile(episode) ? folderOf(episode.filePath ?? "") : episode.podcastName; @@ -164,12 +176,7 @@ export function episodeRowModel(episode: Episode, state: RowState): EpisodeRowMo : local ? filePath && folderOf(filePath) : episode.podcastName, - tile: - state.cover && local - ? getEpisodeMediaType(episode) === "video" - ? "video" - : "music" - : undefined, + tile: state.cover ? tileOf(episode) : undefined, meta, progress: left !== undefined && record ? record.time / record.duration : undefined, played, diff --git a/src/ui/PodcastView/index.notes.test.ts b/src/ui/PodcastView/index.notes.test.ts index 5fbaa3cb..b939e098 100644 --- a/src/ui/PodcastView/index.notes.test.ts +++ b/src/ui/PodcastView/index.notes.test.ts @@ -83,11 +83,11 @@ async function openPodcastPage(notes: string[]) { test("a listed episode shows its note as soon as the note path setting finds it", async () => { const { rows, settings } = await openPodcastPage(["Elsewhere/Episode One.md"]); const row = await rows.findByRole("button", { name: "Episode One" }); - expect(row.querySelector('[title="Has note"]')).toBeNull(); + expect(row.querySelector('[data-icon="file-text"]')).toBeNull(); settings.note.path = "Elsewhere/{{title}}.md"; pluginStore.set(get(pluginStore)); expect(await rows.findByRole("button", { name: "Episode One, has note" })).toBe(row); - expect(row.querySelector('[title="Has note"]')).not.toBeNull(); + expect(row.querySelector('[data-icon="file-text"]')).not.toBeNull(); }); diff --git a/src/ui/PodcastView/menus.test.ts b/src/ui/PodcastView/menus.test.ts index d1a44a53..2b4649db 100644 --- a/src/ui/PodcastView/menus.test.ts +++ b/src/ui/PodcastView/menus.test.ts @@ -204,7 +204,7 @@ describe("an episode's menu", () => { expect(titles(menu(local))).not.toContain("Download"); downloadedEpisodes.addEpisode(local, "Recordings/Interview.wav", 4); - expect(titles(menu(local))).toContain("Remove from Local Files"); + expect(titles(menu(local))).toContain("Remove from Local files"); }); test("Favorites asks for the state the menu showed, so a second click changes nothing", () => { @@ -473,6 +473,18 @@ describe("a podcast's menu, by how its feed loaded", () => { expect(titles(podcastMenu(key, services))).toEqual(items); }); + test("Set feed address waits for a connection, which checks the address", () => { + const setFeedAddress = (podcast: string) => + podcastMenu(podcast, services).find( + (entry) => entry !== "separator" && entry.title === "Set feed address", + ); + + services.online.set(false); + expect(setFeedAddress("Patron")).toMatchObject({ disabled: true }); + services.online.set(true); + expect(setFeedAddress("Patron")).toMatchObject({ disabled: false }); + }); + test("Edit feed URL asks for the failed feed's address", () => { const setFeedAddress = vi.spyOn(services.actions, "setFeedAddress").mockResolvedValue(); @@ -623,6 +635,42 @@ describe("a menu", () => { expect(escape(), "an Escape once the menu is gone").toBe(false); }); + test("on a phone, where it hides after 150 ms, the Escape that closes it is handled and focus is back at once", () => { + const menus = obsidian.Menu as unknown as typeof FakeMenu; + vi.useFakeTimers(); + menus.hideDelayMs = 150; + onTestFinished(() => { + menus.hideDelayMs = 0; + vi.useRealTimers(); + }); + const keymap = (event: KeyboardEvent) => { + if (event.key === "Escape") menus.shown?.hide(); + }; + window.addEventListener("keydown", keymap, true); + onTestFinished(() => window.removeEventListener("keydown", keymap, true)); + const escape = () => { + const event = new KeyboardEvent("keydown", { key: "Escape", cancelable: true }); + document.body.dispatchEvent(event); + return event.defaultPrevented; + }; + const note = document.body.appendChild(document.createElement("textarea")); + onTestFinished(() => note.remove()); + + showMenu([{ title: "Item", onClick: () => {} }], at); + note.focus(); + expect(escape(), "the Escape that closes the menu").toBe(true); + expect(trigger, "focus back before the animation ends").toHaveFocus(); + note.focus(); + vi.advanceTimersByTime(150); + expect(note, "the animation's end moves no focus").toHaveFocus(); + expect(escape(), "an Escape once the menu is gone").toBe(false); + + showMenu([{ title: "Open", onClick: () => note.focus() }], at); + menus.shown!.pick("Open"); + vi.advanceTimersByTime(150); + expect(note, "a picked item keeps the focus it set").toHaveFocus(); + }); + describe("gives focus back to the control that opened it", () => { const shown = () => (obsidian.Menu as unknown as typeof FakeMenu).shown!; let note: HTMLTextAreaElement; diff --git a/src/ui/PodcastView/menus.ts b/src/ui/PodcastView/menus.ts index b5395b75..52606fa8 100644 --- a/src/ui/PodcastView/menus.ts +++ b/src/ui/PodcastView/menus.ts @@ -58,8 +58,20 @@ export function anchorOf(event: MouseEvent): MenuAnchor { * keeps the focus it sets. Obsidian runs an item before it hides the menu. */ export function showMenu(entries: readonly MenuEntry[], at: MenuAnchor): void { - const menu = new Menu(); let picked = false; + const menu = new (class extends Menu { + // Obsidian's menu passes on the Escape that closes it, and Obsidian sends focus from a + // sidebar to the main area on an Escape nothing handled. This one counts as handled. + // On a phone the menu hides after a 150 ms animation, when the event is long gone, so + // this runs as the hide starts, inside that keydown. + override hide() { + const closing = at.el.win.event; + if (closing?.type === "keydown" && "key" in closing && closing.key === "Escape") + closing.preventDefault(); + if (!picked && at.el.isConnected) at.el.focus(); + return super.hide(); + } + })(); for (const entry of entries) { if (entry === "separator") { menu.addSeparator(); @@ -77,16 +89,6 @@ export function showMenu(entries: readonly MenuEntry[], at: MenuAnchor): void { if (entry.disabled) item.setDisabled(true); }); } - menu.onHide(() => { - // Obsidian's menu passes on the Escape that closes it, and Obsidian sends focus from - // a sidebar to the main area on an Escape nothing handled. This one counts as handled. - // It works because the menu hides synchronously inside that keydown. On a phone the - // hide is animated, so the event is gone by then and this does nothing. - const closing = at.el.win.event; - if (closing?.type === "keydown" && "key" in closing && closing.key === "Escape") - closing.preventDefault(); - if (!picked && at.el.isConnected) at.el.focus(); - }); menu.showAtPosition(at, at.doc); } @@ -211,7 +213,7 @@ export function episodeMenu( // Removing a local file forgets it and never trashes it. entries.push( item( - "Remove from Local Files", + "Remove from Local files", "trash", () => void removeDownloadedEpisode(episode), ), @@ -270,15 +272,17 @@ export function episodeMenu( } export function podcastMenu(key: FeedKey, services: ShellServices): MenuEntry[] { - const { library, feeds, actions, navigation } = services; + const { library, feeds, actions, navigation, online } = services; const feed = get(library.savedFeeds)[key]; if (!feed) return []; const note = getFeedNote(feed); const error = get(feeds).get(key)?.error; const isPublic = publicFeedUrl(feed) !== undefined; + // An address is checked by loading its feed. const address = (title: string): MenuEntry => ({ title, icon: "rss", + disabled: !get(online), onClick: () => void actions.setFeedAddress(key), }); return [ diff --git a/src/ui/PodcastView/reducedMotion.test.ts b/src/ui/PodcastView/reducedMotion.test.ts deleted file mode 100644 index 4b7454d7..00000000 --- a/src/ui/PodcastView/reducedMotion.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// @vitest-environment jsdom -import { readFileSync } from "node:fs"; -import postcss, { type Rule } from "postcss"; -import { compile } from "svelte/compiler"; -import { expect, test } from "vitest"; - -/** - * The play button's computed transition under Deck's own styles. jsdom cascades them - * once their nesting is flattened, and applies only `all` media, so `reduced` turns the - * reduced-motion query into `all` the way the user's setting would make it match. - */ -function playTransition(reduced: boolean): string { - const source = readFileSync(`${import.meta.dirname}/Deck.svelte`, "utf8"); - const { css } = compile(source, { css: "external", cssHash: () => "pn-test" }); - const sheet = postcss.parse(css?.code ?? ""); - const nested: Rule[] = []; - sheet.walkRules((rule) => { - if (rule.parent?.type === "rule") nested.push(rule); - }); - const last = new Map(); - for (const rule of nested) { - const parent = rule.parent as Rule; - rule.selectors = parent.selectors.flatMap((outer) => - rule.selectors.map((s) => `${outer} ${s}`), - ); - (last.get(parent) ?? parent).after(rule); - last.set(parent, rule); - } - const style = document.createElement("style"); - style.textContent = reduced - ? sheet.toString().replaceAll("(prefers-reduced-motion: reduce)", "all") - : sheet.toString(); - document.head.replaceChildren(style); - document.body.innerHTML = - '
'; - return getComputedStyle(document.querySelector(".pn-play")!).transition; -} - -test("the play button presses without a transition when the user prefers reduced motion", () => { - expect([playTransition(false), playTransition(true)]).toEqual(["transform 120ms ease", "none"]); -}); diff --git a/src/ui/PodcastView/styles.test.ts b/src/ui/PodcastView/styles.test.ts new file mode 100644 index 00000000..26da6d8a --- /dev/null +++ b/src/ui/PodcastView/styles.test.ts @@ -0,0 +1,266 @@ +// @vitest-environment jsdom +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import postcss, { type AtRule, type Node } from "postcss"; +import { compile } from "svelte/compiler"; +import { describe, expect, test } from "vitest"; +import { componentCss, styled } from "../../../tests/mocks/componentCss"; + +const REDUCE = "(prefers-reduced-motion: reduce)"; +const UI = path.resolve(import.meta.dirname, ".."); + +/** `html` in the view, with `{}` standing for the component's own scoping class. */ +const inView = (component: string, html: string) => + `
${html.replaceAll("{}", `pn-${component}`)}
`; + +describe("reduced motion", () => { + test.each([ + [ + "the play button", + "Deck", + '
', + ".pn-play", + ["transform 120ms ease", ""], + ], + [ + "a podcast card", + "LibraryPage", + '
', + ".pn-gridcard", + ["transform 120ms ease", ""], + ], + [ + "a playing row's bars", + "EpisodeRow", + '
', + ".pn-eq i", + ["", "pn-eq 1s ease-in-out infinite"], + ], + ])( + "%s stops moving when the user prefers reduced motion", + (_name, component, html, selector, moving) => { + const motion = (media: string[]) => { + const css = componentCss(["PodcastView", component], { media }); + const { transition, animation } = getComputedStyle( + styled(css, inView(component, html), selector), + ); + return [transition, animation]; + }; + + expect([motion([]), motion([REDUCE])]).toEqual([moving, ["none", "none"]]); + }, + ); +}); + +describe("focus ring", () => { + test("draws the video's play button ring inside the frame that clips it, and others outside", () => { + const css = componentCss(["PodcastView", "Player"], { states: ["focus-visible"] }); + styled( + css, + inView( + "Player", + '
', + ), + ".pn-vplay", + ); + const ring = (selector: string) => { + const { outline, outlineOffset } = getComputedStyle(document.querySelector(selector)!); + return [outline, outlineOffset]; + }; + + expect([ring(".pn-vplay"), ring(".pn-iconbtn")]).toEqual([ + ["2px solid var(--interactive-accent)", "-2px"], + ["2px solid var(--interactive-accent)", "1px"], + ]); + }); +}); + +describe("hover", () => { + const HOVER = "(hover: hover)"; + + test("only a device that can hover lights up anything in the view", () => { + const unguarded: string[] = []; + for (const file of readdirSync(UI, { recursive: true, encoding: "utf8" })) { + if (!file.endsWith(".svelte")) continue; + const { css } = compile(readFileSync(path.join(UI, file), "utf8"), { css: "external" }); + postcss.parse(css?.code ?? "").walkRules((rule) => { + let guarded = false; + for (let at: Node | undefined = rule.parent; at; at = at.parent) + if (at.type === "atrule" && (at as AtRule).params === HOVER) guarded = true; + if (rule.selector.includes(":hover") && !guarded) + unguarded.push(`${file} ${rule.selector}`); + }); + } + + expect(unguarded).toEqual([]); + }); + + // % in each markup is where a hovered element takes the `hover` class. + test.each([ + [ + "Back", + "PodcastView", + '', + ".pn-back", + "rgba(0, 0, 0, 0)", + ], + ["a row", "EpisodeRow", '
', ".pn-ep", ""], + [ + "the skip button", + "Deck", + '
', + ".pn-skip", + "", + ], + [ + "the panel switch that is on", + "Player", + '
', + ".pn-sw", + "var(--background-modifier-border)", + ], + ])( + "%s keeps its resting background after a tap", + (_name, component, html, selector, resting) => { + const background = (hovered: boolean, media: string[] = []) => { + const css = componentCss(["PodcastView", component], { media, states: ["hover"] }); + const markup = inView(component, html.replace("%", hovered ? " hover" : "")); + return getComputedStyle(styled(css, markup, selector)).background; + }; + + expect([background(false), background(true), background(true, [HOVER])]).toEqual([ + resting, + resting, + "var(--background-modifier-hover)", + ]); + }, + ); +}); + +describe("the transcript's note button", () => { + test("in a narrow panel's head, clips its label out of sight but keeps it as its name, and shows it in an empty panel", () => { + const css = componentCss(["PodcastView", "TranscriptPanel"], { + media: ["(width < 340px)"], + }); + const button = + ''; + styled( + css, + `
${button}
${button}
`, + ".pn-tx", + ); + const label = (place: string) => { + const { display, visibility, position, clipPath } = getComputedStyle( + document.querySelector(`.${place} .pn-tx-save span:last-child`)!, + ); + return { display, visibility, position, clipPath }; + }; + + expect([label("pn-panel-head"), label("pn-panel-empty")]).toEqual([ + { + display: "inline", + visibility: "visible", + position: "absolute", + clipPath: "inset(50%)", + }, + { display: "inline", visibility: "visible", position: "static", clipPath: "none" }, + ]); + }); +}); + +describe("on a tablet", () => { + test("the rail's add button keeps its own padding over Obsidian's for every button", () => { + const obsidian = + ".is-tablet button:not(.clickable-icon) { padding: var(--size-4-1) var(--size-4-5); }"; + // jsdom drops a var() in a shorthand, so Obsidian's 4 px spacing steps stand in. + const css = (obsidian + componentCss(["PodcastView", "Rail"])).replace( + /var\(--size-4-(\d)\)/g, + (_, step: string) => `${Number(step) * 4}px`, + ); + const { padding } = getComputedStyle( + styled( + css, + `
${inView("Rail", '')}
`, + ".pn-railsearch", + ), + ); + + expect(padding).toBe("0px 8px"); + }); +}); + +describe("the player's stage", () => { + test("dresses its own artwork, not the covers of a panel's rows shown in it", () => { + const css = componentCss(["PodcastView", "Player", "EpisodeRow"]); + const tile = (id: string) => ``; + styled( + css, + `
${tile("art")}
`, + "#art", + ); + const look = (id: string) => { + const style = getComputedStyle(document.getElementById(id)!); + return { shadow: style.boxShadow, icon: style.getPropertyValue("--icon-size") }; + }; + + expect([look("art"), look("row")]).toEqual([ + { shadow: "var(--shadow-s)", icon: "25cqw" }, + { shadow: "", icon: "18px" }, + ]); + }); +}); + +describe("a disabled control", () => { + test("shows at Obsidian's 0.4 for a disabled icon, dimmed once, hovered or not; the scrubber at its own 0.5", () => { + const obsidian = 'button[disabled], button[aria-disabled="true"] { opacity: 0.7; }'; + const css = + obsidian + + componentCss(["PodcastView", "Player", "EpisodeRow", "Scrubber"], { + media: ["(hover: hover)"], + states: ["hover"], + }); + styled( + css, + `
+ + + +
+
+
+
`, + "#icon", + ); + const shown = (id: string) => { + let opacity = 1; + for (let at: Element | null = document.getElementById(id); at; at = at.parentElement) + opacity *= parseFloat(getComputedStyle(at).opacity) || 1; + return Math.round(opacity * 100) / 100; + }; + + expect(["icon", "pressed", "cta", "switch", "row", "cover", "scrub"].map(shown)).toEqual([ + 0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.5, + ]); + }); +}); + +describe("the search field", () => { + test.each([ + ["on a desktop", "", ""], + ["on a phone", "is-phone", ""], + ["on a phone's first run", "is-phone", "is-large"], + ])("%s, ends its text where the clear button starts", (_name, device, size) => { + const css = componentCss(["PodcastView", "SearchField"]); + styled( + css, + `
`, + ".pn-input", + ); + const { paddingRight } = getComputedStyle(document.querySelector(".pn-input")!); + const { right, width } = getComputedStyle(document.querySelector(".pn-clear")!); + + expect(parseFloat(paddingRight)).toBeGreaterThanOrEqual( + parseFloat(right) + parseFloat(width), + ); + }); +}); diff --git a/src/ui/PodcastView/transcript.ts b/src/ui/PodcastView/transcript.ts index 9bb13145..e433c85b 100644 --- a/src/ui/PodcastView/transcript.ts +++ b/src/ui/PodcastView/transcript.ts @@ -25,6 +25,14 @@ export function lineAt(lines: readonly { start: number }[], time: number): numbe return at; } +/** + * Lower case for Find that keeps the text's length, so a match's place in the folded text is + * its place in the text: "ฤฐ" lowercases to two characters, so it folds to "i". + */ +export function foldCase(text: string): string { + return text.replace(/\u0130/g, "i").toLowerCase(); +} + let cached: { key: string; transcript: Transcript | null } | undefined; /** Undefined when not cached; null when the episode's feed publishes no transcript. */ diff --git a/src/ui/removePreviousStyles.test.ts b/src/ui/removePreviousStyles.test.ts new file mode 100644 index 00000000..782400b9 --- /dev/null +++ b/src/ui/removePreviousStyles.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import type { Workspace } from "obsidian"; +import type { UserConfig } from "vite"; +import { afterEach, expect, test, vi } from "vitest"; +import { openPopout } from "../../tests/mocks/popout"; +import { removePreviousStyles } from "./removePreviousStyles"; + +vi.mock("@sveltejs/vite-plugin-svelte", () => ({ + svelte: (options: unknown) => ({ name: "svelte-options", options }), +})); + +type CssHash = (input: { hash: (css: string) => string; css: string }) => string; + +/** The id the production build gives the sheet of a component whose CSS hashes to `hash`. */ +async function builtSheetId(hash: string): Promise { + const { default: config } = await import("../../vite.config"); + const resolved = (config as (env: object) => UserConfig)({ + mode: "production", + command: "build", + }); + const svelte = resolved.plugins?.find( + (plugin) => plugin && "name" in plugin && plugin.name === "svelte-options", + ) as unknown as { options: { compilerOptions: { cssHash: CssHash } } }; + return svelte.options.compilerOptions.cssHash({ hash: () => hash, css: "" }); +} + +function addSheet(doc: Document, id: string): void { + const style = doc.createElement("style"); + style.id = id; + doc.head.append(style); +} + +const sheetIds = (doc: Document) => + [...doc.head.querySelectorAll("style")].map((style) => style.id); + +function workspaceWithLeavesIn(...docs: Document[]): Workspace { + return { + iterateAllLeaves: (callback: (leaf: unknown) => void) => { + for (const doc of docs) callback({ getContainer: () => ({ doc }) }); + }, + } as unknown as Workspace; +} + +afterEach(() => { + document.head.replaceChildren(); +}); + +test("removes the sheets of an earlier build from the main window and every popout, and no other sheet", async () => { + const popout = openPopout().win.document; + const earlier = await builtSheetId("earlier"); + for (const doc of [document, popout]) { + addSheet(doc, earlier); + addSheet(doc, "svelte-1x2y3z"); + addSheet(doc, "obsidian-theme"); + } + + removePreviousStyles(workspaceWithLeavesIn(document, popout, popout)); + + expect(sheetIds(document)).toEqual(["svelte-1x2y3z", "obsidian-theme"]); + expect(sheetIds(popout)).toEqual(["svelte-1x2y3z", "obsidian-theme"]); +}); + +test("removes the main window's sheets when no leaf is open", async () => { + addSheet(document, await builtSheetId("earlier")); + + removePreviousStyles(workspaceWithLeavesIn()); + + expect(sheetIds(document)).toEqual([]); +}); diff --git a/src/ui/removePreviousStyles.ts b/src/ui/removePreviousStyles.ts new file mode 100644 index 00000000..4986b2c8 --- /dev/null +++ b/src/ui/removePreviousStyles.ts @@ -0,0 +1,15 @@ +import type { Workspace } from "obsidian"; + +/** + * Removes the style sheets an earlier PodNotes bundle injected. Obsidian updates a plugin + * without reloading its windows, and Svelte only appends a component's sheet, so an old + * sheet would keep applying until a restart. Popouts are swept too: Obsidian mirrors the + * main window's sheets into them but never removes one Svelte appended there directly. + */ +export function removePreviousStyles(workspace: Workspace): void { + const docs = new Set([window.document]); + workspace.iterateAllLeaves((leaf) => docs.add(leaf.getContainer().doc)); + for (const doc of docs) { + for (const style of doc.head.querySelectorAll('style[id^="pn-"]')) style.remove(); + } +} diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index f86eaf01..7a2fb5f4 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -191,6 +191,48 @@ describe("sections", () => { ]); }); + test("each field is named after its row where it takes focus, without an aria-label Obsidian would show as a tooltip", () => { + const { tab } = open(); + const fields = [ + ...tab.containerEl.querySelectorAll( + '.setting-item-control :is(input:not([tabindex="-1"]), select, textarea, [tabindex="0"])', + ), + ]; + const rowOf = (field: Element) => + field.closest(".setting-item")?.querySelector(".setting-item-name")?.textContent; + + expect(fields.map(rowOf)).toEqual([ + "Skip back (s)", + "Skip forward (s)", + "Default speed", + "Queue episodes I switch away from", + "Previous track", + "Next track", + "Timestamp template", + "Timestamp offset (s)", + "Episode note path", + "Episode note template", + "Podcast note path", + "Podcast note template", + "OpenAI API key", + "Transcript path", + "Transcript template", + "Speaker diarization", + "Diarization provider", + "Deepgram API key", + "Speaker label format", + "Download path", + "Export OPML", + "Latest episodes per podcast", + "Include API keys", + "Export settings", + ]); + for (const field of fields) { + expect(field).toHaveAccessibleName(rowOf(field)); + expect(field).not.toHaveAttribute("aria-label"); + } + }); + test("each row renders under its own name, and the speaker rows apply only with diarization", () => { const { row, toggle, select } = open(); const disabled = (name: string) => row(name).classList.contains("is-disabled"); @@ -478,7 +520,7 @@ describe("text fields", () => { retype(path, "PodNotes/{{podcast}}"); expect(warning()).toBe( - "Without {{title}}, episodes share one file and downloads overwrite each other. Add it, for example PodNotes/{{podcast}}/{{title}}.", + "Without {{title}}, episodes share a file name and a second download to it fails. Add it, for example PodNotes/{{podcast}}/{{title}}.", ); type(path, "/{{title}}"); expect(warning()).toBeUndefined(); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index f0a4f3e0..03088835 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -87,10 +87,29 @@ const TYPING_SAVE_DELAY_MS = 500; /** Search finds rows, not section headings. */ const HEADSET_ALIASES = ["headset", "lock screen", "media keys"]; +let settingNames = 0; + +/** + * Obsidian names no row's field after the row, and shows an aria-label as a tooltip, so + * each field is labelled by the row's name. A toggle takes focus on its label, not its + * checkbox. + */ const group = (heading: string, items: SettingDefinitionRender[]): SettingDefinitionGroup => ({ type: "group", heading, - items, + items: items.map((item) => ({ + ...item, + render: (setting, settingGroup) => { + const end = item.render(setting, settingGroup); + const { nameEl, controlEl } = setting; + nameEl.id = `podnotes-setting-${++settingNames}`; + for (const field of controlEl.querySelectorAll( + "input, select, textarea, .checkbox-container", + )) + field.setAttribute("aria-labelledby", nameEl.id); + return end; + }, + })), }); function isPlainRecord(value: unknown): value is Record { @@ -368,7 +387,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { setting.setErrorMessage( /\{\{title(:[^}]*)?\}\}/i.test(value) ? null - : "Without {{title}}, episodes share one file and downloads overwrite each other. Add it, for example PodNotes/{{podcast}}/{{title}}.", + : "Without {{title}}, episodes share a file name and a second download to it fails. Add it, for example PodNotes/{{podcast}}/{{title}}.", ); }, }), diff --git a/tests/e2e/harness-fixtures/reused-vault.fixture.ts b/tests/e2e/harness-fixtures/reused-vault.fixture.ts index efadf8c2..e2a8db26 100644 --- a/tests/e2e/harness-fixtures/reused-vault.fixture.ts +++ b/tests/e2e/harness-fixtures/reused-vault.fixture.ts @@ -3,8 +3,9 @@ import { createPodNotesE2EHarness } from "../harness"; const getContext = createPodNotesE2EHarness("harness-reused-vault"); -test("opens the collapsed left sidebar and loads only its own recorder, before PodNotes", async () => { +test("opens the collapsed left sidebar, is online and loads only its own recorder, before PodNotes", async () => { const { obsidian } = getContext(); + expect(await obsidian.dev.evalJson("navigator.onLine"), "online").toBe(true); const left = await obsidian.dev.evalJson<{ collapsed: boolean; size: number; diff --git a/tests/e2e/harness-lifecycle.test.ts b/tests/e2e/harness-lifecycle.test.ts index d516a85b..1dc256c8 100644 --- a/tests/e2e/harness-lifecycle.test.ts +++ b/tests/e2e/harness-lifecycle.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { createObsidianClient, resolveObsidianEnvOptions } from "obsidian-e2e"; import { acquireVaultRunLock } from "obsidian-e2e/vitest"; import { beforeAll, expect, test } from "vitest"; +import { setOffline } from "./journey/actions"; import { jsLiteral, runAllSteps } from "./runtime"; import { assertNoNewRuntimeErrors, @@ -303,7 +304,7 @@ test.each([ ); test.for([true, false])( - "recovers a stale recorder and collapsed sidebar with PodNotes initially enabled=%s", + "recovers a stale recorder, a collapsed sidebar and an offline renderer with PodNotes initially enabled=%s", { timeout: 120_000 }, async (initiallyEnabled, { signal }) => { const dir = await obsidianDir(); @@ -351,6 +352,7 @@ test.for([true, false])( await app.vault.adapter.promise; return true; })()`); + await setOffline(obsidian, true); return { config: await fs.readFile(path.join(dir, "community-plugins.json"), "utf8"), workspace: await fs.readFile(workspacePath), @@ -379,6 +381,7 @@ test.for([true, false])( } finally { await withVaultLock(() => runAllSteps( + () => setOffline(obsidian, false), () => obsidian.dev.evalJsonAsync(`(async () => { await app.plugins.disablePlugin(${jsLiteral(stale)}); diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 16f35e80..7295c284 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -20,6 +20,7 @@ import { assertSettingsOpenInMainWindow, assertSingleWindow, dismissNotices, + setOffline, } from "./journey/actions"; import { hasJourneySnapshot, @@ -86,6 +87,9 @@ export function createPodNotesE2EHarness(testName: string) { 'app.saveLocalStorage("secrets-encryption-warning-dismissed", "1"), true', ); await dismissNotices(suiteClient); + // The verify skill's `screens.mjs offline on` leaves the renderer offline through + // CDP, where no feed loads, and the instance stays up for the next run. + await setOffline(suiteClient, false); const resolver = await suiteClient.dev.evalJson( 'require("@electron/remote").app.commandLine.getSwitchValue("host-resolver-rules")', ); diff --git a/tests/e2e/journey-player.test.ts b/tests/e2e/journey-player.test.ts index f00cad7f..1b9889bc 100644 --- a/tests/e2e/journey-player.test.ts +++ b/tests/e2e/journey-player.test.ts @@ -114,7 +114,7 @@ async function expectVideoFrame(when: string, minWidth: number): Promise { /** The titles in the player's Up next panel, in order. */ async function upNext(): Promise { return await getContext().obsidian.dev.evalJson( - `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-player .pn-queue .pn-ep-title`)})].map((el) => el.textContent.trim())`, + `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-player [role="list"][aria-label="Queue"] .pn-ep-title`)})].map((el) => el.textContent.trim())`, ); } @@ -133,47 +133,42 @@ const focused = (target: UiTarget) => ); describe("player journeys", () => { - test("the fullscreen button belongs to the video stage and hides while that stage is fullscreen", async () => { + test("in a video's fullscreen, the first Escape only leaves it, and its own button leaves it too", async () => { const { obsidian } = getContext(); await openPlayer([], () => ({ mediaType: "video" })); await click(obsidian, ui.playbackToggle); await waitForPlayback(obsidian, (state) => state.playing, "video loaded"); - const fullscreen: UiTarget = { - name: "video fullscreen", - elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-video button.pn-fs`)})]`, - }; - expect(await isShown(obsidian, fullscreen)).toBe(true); + const control = (label: string): UiTarget => ({ + name: `video ${label}`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-video button.pn-fs[aria-label="${label}"]`)})]`, + }); + const frameIsFullscreen = () => + obsidian.dev.evalJson( + `document.fullscreenElement === document.querySelector(${jsLiteral(`${PLAYER_VIEW} .pn-video`)})`, + ); onTestFinished(async () => { await obsidian.dev.evalJsonAsync( "(async () => { if (document.fullscreenElement) await document.exitFullscreen(); return true; })()", ); }); - await click(obsidian, fullscreen); - expect( - await eventually( - obsidian, - () => - obsidian.dev.evalJson( - `document.fullscreenElement === document.querySelector(${jsLiteral(`${PLAYER_VIEW} .pn-video`)})`, - ), - true, - ), - ).toBe(true); - expect( - await obsidian.dev.evalJson( - `getComputedStyle((${fullscreen.elements})[0]).display`, - ), - ).toBe("none"); - + await click(obsidian, control("Fullscreen")); + expect(await eventually(obsidian, frameIsFullscreen, true)).toBe(true); + await waitUntilShown(obsidian, control("Exit fullscreen")); + await pressKey(obsidian, "Escape"); + expect(await eventually(obsidian, frameIsFullscreen, false)).toBe(false); + expect(await holdsFor(obsidian, () => isShown(obsidian, ui.fullPlayer), true)).toBe(true); + + await click(obsidian, control("Fullscreen")); + expect(await eventually(obsidian, frameIsFullscreen, true)).toBe(true); + await click(obsidian, control("Exit fullscreen")); + expect(await eventually(obsidian, frameIsFullscreen, false)).toBe(false); + await waitUntilShown(obsidian, control("Fullscreen")); await pressKey(obsidian, "Escape"); expect( - await eventually( - obsidian, - () => obsidian.dev.evalJson("document.fullscreenElement === null"), - true, - ), - ).toBe(true); + await eventually(obsidian, () => isShown(obsidian, ui.fullPlayer), false), + "the next Escape closes the player", + ).toBe(false); }); test.each(["theme-light", "theme-dark"])( diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 26d888a0..fa0eb8cc 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -2,15 +2,16 @@ import { describe, expect, onTestFinished, test } from "vitest"; import { FIXTURE_HOST, SUPERCAST_FIXTURE_HOST } from "../../scripts/e2e-instance.mjs"; import { createPodNotesE2EHarness } from "./harness"; import { + asPhone, dismissNotices, runCommand, - setMobileEmulation, + placePodNotes, setOffline, showAddPodcast, showLibrary, showPodNotes, } from "./journey/actions"; -import { cdp, click, pressKey, typeInto, typeText } from "./journey/input"; +import { accessibleName, click, pressKey, typeInto, typeText } from "./journey/input"; import { PLAYER_VIEW, ui, type UiTarget } from "./journey/targets"; import { eventually, isDisabled, isShown, notices, waitUntilShown } from "./journey/wait"; import { @@ -26,6 +27,7 @@ import { storedEpisode, waitForLibrary, waitForPlayback, + writeVaultFile, } from "./journey/library"; import { jsLiteral, PLUGIN_ID } from "./runtime"; @@ -110,6 +112,7 @@ async function serve(): Promise { return server; } +/** The full player wide, so every line shows and none needs a scroll that stops following. */ async function openTranscript(episode: FixtureEpisode): Promise { const context = getContext(); const server = await serve(); @@ -117,7 +120,7 @@ async function openTranscript(episode: FixtureEpisode): Promise { subscriptions: [FEED], current: storedEpisode(server, FEED, episode), }); - await showPodNotes(context.obsidian); + await placePodNotes(context.obsidian, { sidebar: 820 }); if (!(await isShown(context.obsidian, ui.fullPlayer))) await click(context.obsidian, ui.openPlayer); await waitUntilShown(context.obsidian, ui.fullPlayer); @@ -144,6 +147,106 @@ async function takeNote(): Promise { return text.replace(/^Date: .+$/m, "Date: "); } +type Box = Readonly<{ top: number; bottom: number; width: number; height: number }>; +type PhoneGeometry = Readonly<{ + stage: Box; + art: Box | null; + panel: Box | null; + list: Box | null; + controls: number; + navbar: number; +}>; + +/** Where the full player lays out its stage and deck on the phone, against Obsidian's navbar. */ +function phoneGeometry(): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const player = document.querySelector(${jsLiteral(`${PLAYER_VIEW} section.pn-player`)}); + const box = (el) => { + if (!el) return null; + const { top, bottom, width, height } = el.getBoundingClientRect(); + return { top, bottom, width, height }; + }; + return { + stage: box(player.querySelector(".pn-stage")), + art: box(player.querySelector(".pn-stage > .pn-cover")), + panel: box(player.querySelector(".pn-stage .pn-panel")), + list: box(player.querySelector(".pn-stage .pn-panel-scroll")), + controls: Math.max(...[...player.querySelectorAll(".pn-deck button")].map((button) => button.getBoundingClientRect().bottom)), + navbar: document.querySelector(".mobile-navbar").getBoundingClientRect().top, + }; + })()`); +} + +type TranscriptHead = Readonly<{ + panel: number; + saveInside: boolean; + label: boolean; + headFits: boolean; + name?: string; + ariaLabel: boolean; +}>; + +const SAVE = `document.querySelector(${jsLiteral(`${PLAYER_VIEW} section.pn-player .pn-panel-head .pn-tx-save`)})`; + +/** + * How the transcript panel's head fits the panel: Save inside it, with its label in sight or + * not, the name Chromium gives Save, and whether it has an aria-label, which Obsidian shows as a tooltip. + */ +async function transcriptHead(): Promise { + const { obsidian } = getContext(); + const head = await obsidian.dev.evalJson(`(() => { + const save = ${SAVE}; + const panel = save.closest(".pn-panel"); + const head = save.parentElement; + const label = save.lastElementChild.getBoundingClientRect(); + const shown = label.width > 1; + const box = panel.getBoundingClientRect(); + const right = Math.max(save.getBoundingClientRect().right, shown ? label.right : 0); + return { + panel: Math.round(box.width), + saveInside: right <= box.right + 0.5, + label: shown, + headFits: head.scrollWidth <= head.clientWidth, + ariaLabel: save.hasAttribute("aria-label"), + }; + })()`); + return { ...head, name: (await accessibleName(obsidian, SAVE))?.name }; +} + +type Followed = Readonly<{ line: string; clear: boolean; cut: boolean; rows: number[] }>; + +/** + * The line playing now, whether it and the line before it show whole, below the list's faded + * top 12 px and above its faded bottom 20 px, and whether a line shows cut where the top fade + * ends. + */ +function followed(): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const list = ${TRANSCRIPT_LIST}; + const top = list.getBoundingClientRect().top; + const now = list.querySelector("[aria-current='true']").closest("li"); + const [before, after] = [now.previousElementSibling, now].map((row) => row.getBoundingClientRect()); + return { + line: now.lastElementChild.lastElementChild.textContent.trim(), + clear: before.top - top > 11.5 && after.bottom - top < list.clientHeight - 19.5, + cut: [...list.children].some((row) => { + const box = row.getBoundingClientRect(); + return box.top - top < 11.5 && box.bottom - top > 12.5; + }), + rows: [before.top - top, after.top - top, after.bottom - top, list.clientHeight].map(Math.round), + }; + })()`); +} + +/** The full player's Transcript panel, once it lists every line. */ +async function openTranscriptPanel(): Promise { + const { obsidian } = getContext(); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); +} + describe("transcript journeys", () => { test("switching episodes clears Find and shows every line of the incoming transcript", async () => { const context = getContext(); @@ -162,9 +265,8 @@ describe("transcript journeys", () => { current: storedEpisode(server, feed, CAPTIONED), queue: [storedEpisode(server, feed, incoming)], }); - await showPodNotes(obsidian); + await placePodNotes(obsidian, { sidebar: 820 }); if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); - await obsidian.dev.evalJson("app.workspace.rightSplit.setSize(820), true"); await click(obsidian, ui.playerPanel("Transcript")); expect( await eventually(obsidian, async () => (await shownTranscript()).lines, LINES), @@ -262,6 +364,39 @@ describe("transcript journeys", () => { expect(fromPanel).toBe(fromCommand); }, 120_000); + test("J-TR-04 a transcript saved before opens from the panel, offline too, unchanged and with no download", async () => { + const { obsidian, sandbox } = getContext(); + const server = await openTranscript(CAPTIONED); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); + const saved = "# Memory Palaces\n\nSaved before, by hand.\n"; + await writeVaultFile(obsidian, sandbox.path(NOTE_PATH), saved); + onTestFinished(() => + obsidian.dev.evalJsonAsync(`(async () => { + await app.vault.delete(app.vault.getFileByPath(${jsLiteral(sandbox.path(NOTE_PATH))})); + return true; + })()`), + ); + await waitUntilShown(obsidian, panelButton("Open note")); + const requests = server.requests().length; + await setOffline(obsidian, true); + onTestFinished(() => setOffline(obsidian, false)); + + await click(obsidian, panelButton("Open note")); + + expect( + await eventually( + obsidian, + () => + obsidian.dev.evalJson( + "app.workspace.getActiveFile()?.path ?? null", + ), + sandbox.path(NOTE_PATH), + ), + ).toBe(sandbox.path(NOTE_PATH)); + expect(await readVaultText(obsidian, sandbox.path(NOTE_PATH))).toBe(saved); + expect(server.requests().length, "no request").toBe(requests); + }); + test("an episode whose feed publishes no transcript offers the paid transcription", async () => { const { obsidian } = getContext(); await openTranscript(PLAIN); @@ -369,21 +504,10 @@ describe("transcript journeys", () => { ); }, 120_000); - test("on a phone, Save, the Find field, Follow and Up next's Edit are 44 px tall", async () => { + test("on a phone, the stage gets the room the deck can spare, and Save, the Find field, Follow and Edit are 44 px", async () => { const context = getContext(); const { obsidian } = context; - const phone = { width: 390, height: 844 }; - await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { - ...phone, - deviceScaleFactor: 1, - mobile: true, - screenWidth: phone.width, - screenHeight: phone.height, - }); - onTestFinished(async () => { - await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); - }); - await setMobileEmulation(obsidian, true); + await asPhone(obsidian); expect( await obsidian.dev.evalJson('document.body.classList.contains("is-phone")'), "a phone", @@ -394,15 +518,17 @@ describe("transcript journeys", () => { current: storedEpisode(server, FEED, CAPTIONED), queue: [storedEpisode(server, FEED, PLAIN)], }); - await showPodNotes(obsidian); + await placePodNotes(obsidian, "main area"); if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); await waitUntilShown(obsidian, ui.fullPlayer); + const geometry = await phoneGeometry(); await click(obsidian, ui.playerPanel("Transcript")); await eventually( obsidian, async () => (await shownTranscript()).lines.length, LINES.length, ); + const transcriptPanel = await phoneGeometry(); const height = (target: UiTarget) => obsidian.dev.evalJson( `Math.round((${target.elements})[0]?.getBoundingClientRect().height ?? 0)`, @@ -421,7 +547,94 @@ describe("transcript journeys", () => { await click(obsidian, ui.playerPanel("Up next")); await waitUntilShown(obsidian, panelButton("Edit")); heights.edit = await height(panelButton("Edit")); + const upNextPanel = await phoneGeometry(); + const editWidth = await obsidian.dev.evalJson( + `(${panelButton("Edit").elements})[0].getBoundingClientRect().width`, + ); expect(heights).toEqual({ save: 44, follow: 44, find: 44, edit: 44 }); + expect(editWidth).toBeGreaterThanOrEqual(44); + expect(geometry.stage.height, "the stage under Obsidian's header").toBeGreaterThanOrEqual( + 275, + ); + expect(geometry.art, "square artwork that fills the stage's height").toEqual({ + top: geometry.stage.top + 4, + bottom: geometry.stage.bottom - 4, + width: geometry.stage.height - 8, + height: geometry.stage.height - 8, + }); + for (const { controls, navbar } of [geometry, transcriptPanel, upNextPanel]) + expect(controls, "the deck above Obsidian's navbar").toBeLessThanOrEqual(navbar); + for (const { stage, panel, list } of [transcriptPanel, upNextPanel]) { + expect([panel?.top, panel?.bottom], "a panel fills the stage").toEqual([ + stage.top, + stage.bottom, + ]); + expect(list?.bottom, "its list fills the stage under its head").toBe(stage.bottom); + } + }, 120_000); + + test("the panel's head fits Save, its label out of sight but naming it in a 320 px sidebar and a phone's drawer, in sight in a phone's main area", async () => { + const context = getContext(); + const { obsidian } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + }); + await placePodNotes(obsidian, { sidebar: 320 }); + await openTranscriptPanel(); + const sidebar = await transcriptHead(); + await asPhone(obsidian); + await placePodNotes(obsidian, "right drawer"); + await openTranscriptPanel(); + const drawer = await transcriptHead(); + await placePodNotes(obsidian, "main area"); + await openTranscriptPanel(); + const mainArea = await transcriptHead(); + + const fits = { saveInside: true, headFits: true, name: "Save as note", ariaLabel: false }; + expect({ sidebar, drawer, mainArea }).toMatchObject({ + sidebar: { ...fits, label: false }, + drawer: { ...fits, label: false }, + mainArea: { ...fits, label: true }, + }); + }, 120_000); + + test("following shows the line playing now and the line before it whole, and no line cut under the top fade, in a 320 px sidebar and a phone's drawer", async () => { + const context = getContext(); + const { obsidian } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + progress: [ + { + episode: { podcastName: FEED.title, title: CAPTIONED.title }, + seconds: 19, + duration: 60, + finished: false, + }, + ], + }); + await placePodNotes(obsidian, { sidebar: 320 }); + await openTranscriptPanel(); + const sidebar = await followed(); + await asPhone(obsidian); + await placePodNotes(obsidian, "right drawer"); + await openTranscriptPanel(); + await obsidian.dev.evalJson(`app.plugins.plugins.${PLUGIN_ID}.api.currentTime = 33, true`); + await eventually(obsidian, async () => (await followed()).line, LINES[5]); + const drawer = await followed(); + + expect([sidebar.line, drawer.line]).toEqual([LINES[3], LINES[5]]); + expect(sidebar, `sidebar rows ${sidebar.rows.join(", ")}`).toMatchObject({ + clear: true, + cut: false, + }); + expect(drawer, `drawer rows ${drawer.rows.join(", ")}`).toMatchObject({ + clear: true, + cut: false, + }); }, 120_000); }); diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts index 3fdefb09..7ff4fdbb 100644 --- a/tests/e2e/journey/actions.ts +++ b/tests/e2e/journey/actions.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { launchPodNotesInstance } from "../../../scripts/e2e-instance.mjs"; import type { ObsidianClient } from "obsidian-e2e"; +import { onTestFinished } from "vitest"; import { loadRunnerConfig, resolveInstanceOptions, @@ -12,7 +13,7 @@ import { import { jsLiteral, PLUGIN_ID, VIEW_TYPE, waitForPodNotesReady } from "../runtime"; import { collectRuntimeErrors, POPOUT_RESIZE_LOOP } from "../runtime-evidence"; import { cdp, click, clickAt, type Point, pressHotkey, typeInto } from "./input"; -import { ui } from "./targets"; +import { PLAYER_VIEW, ui } from "./targets"; import { isShown, waitUntilShown } from "./wait"; export async function dismissNotices(obsidian: ObsidianClient): Promise { @@ -42,6 +43,63 @@ export async function showPodNotes(obsidian: ObsidianClient): Promise { await waitUntilShown(obsidian, ui.podcastViewport); } +/** + * Shows PodNotes in one leaf in the right sidebar, `width` px wide, in the main area, + * wherever an earlier test or tool left it, or in the right drawer, where it opens on a + * phone, and closes the leaves elsewhere. + */ +export async function placePodNotes( + obsidian: ObsidianClient, + place: { sidebar: number } | "main area" | "right drawer", +): Promise { + if (place === "right drawer") { + await obsidian.dev.evalJson( + `app.workspace.detachLeavesOfType(${jsLiteral(VIEW_TYPE)}), true`, + ); + await showPodNotes(obsidian); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `Boolean(document.querySelector(${jsLiteral(PLAYER_VIEW)})?.closest(".workspace-drawer.mod-right"))`, + ), + { timeoutMs: 5_000, intervalMs: 100, message: "PodNotes in the right drawer" }, + ); + return; + } + const sidebar = place !== "main area"; + await obsidian.dev.evalJsonAsync(`(async () => { + const { workspace } = app; + const inSidebar = (leaf) => leaf.getRoot() === workspace.rightSplit; + for (const leaf of workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})) + if (inSidebar(leaf) !== ${sidebar}) leaf.detach(); + if (workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)}).length === 0) { + let leaf = ${sidebar} ? workspace.getRightLeaf(false) : undefined; + workspace.iterateRootLeaves((candidate) => { + if (!${sidebar} && (!leaf || candidate.view.getViewType() === "empty")) leaf = candidate; + }); + await leaf.setViewState({ type: ${jsLiteral(VIEW_TYPE)} }); + } + return true; + })()`); + await showPodNotes(obsidian); + if (!sidebar) return; + // A tool such as the verify skill's screens.mjs can leave the window too small for it. + await obsidian.dev.evalJson(`(() => { + const win = require("@electron/remote").getCurrentWindow(); + const [width, height] = win.getContentSize(); + win.setContentSize(Math.max(width, ${place.sidebar + 400}), Math.max(height, 760)); + app.workspace.rightSplit.setSize(${place.sidebar}); + return true; + })()`); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `Math.round(document.querySelector(${jsLiteral(PLAYER_VIEW)})?.getBoundingClientRect().width ?? 0) === ${place.sidebar}`, + ), + { timeoutMs: 5_000, intervalMs: 100, message: `PodNotes ${place.sidebar} px wide` }, + ); +} + /** Goes to the Library as a user does: closes the full player, then Back until the Library shows. */ export async function showLibrary(obsidian: ObsidianClient): Promise { if (await isShown(obsidian, ui.fullPlayer)) await click(obsidian, ui.collapsePlayer); @@ -240,6 +298,40 @@ export async function closePopoutTab(obsidian: ObsidianClient, notePath: string) if (!closed) throw new Error(`The popout showing ${notePath} did not close.`); } +/** Moves the PodNotes view into a new popout window, as the tab menu's "Move to new window" does. */ +export async function moveViewToPopout(obsidian: ObsidianClient): Promise { + const moved = await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + app.workspace.moveLeafToPopout(leaf); + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const moved = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + if (moved?.view.containerEl.ownerDocument !== document) return true; + } + return false; + })()`); + if (!moved) throw new Error("The PodNotes view did not move to a popout."); +} + +// Closes a popout left open, saves the layout so no reload reopens it, and reloads: +// closing a popout leaves Obsidian's layout observers looping in the main window for a +// while, and a fresh window keeps later tests clean. +export async function closePopoutAndReload(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJsonAsync(`(async () => { + require("@electron/remote").getCurrentWindow().show(); + const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + leaf?.detach(); + const windows = () => require("@electron/remote").BrowserWindow.getAllWindows().length; + for (let i = 0; i < 50 && (app.workspace.floatingSplit.children.length > 0 || windows() > 1); i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + app.workspace.requestSaveLayout.cancel(); + await app.workspace.saveLayout(); + return true; + })()`); + await reloadAfterPopout(obsidian); +} + export async function revealInFileExplorer( obsidian: ObsidianClient, filePath: string, @@ -424,6 +516,22 @@ export async function setOffline(obsidian: ObsidianClient, offline: boolean): Pr ); } +/** Obsidian's phone layout on a 390 x 844 screen, until the test ends. */ +export async function asPhone(obsidian: ObsidianClient): Promise { + await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + screenWidth: 390, + screenHeight: 844, + }); + onTestFinished(async () => { + await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); + }); + await setMobileEmulation(obsidian, true); +} + /** Switches Obsidian's mobile emulation, which reloads the app, and waits for PodNotes. */ export async function setMobileEmulation(obsidian: ObsidianClient, on: boolean): Promise { await collectRuntimeErrors(obsidian); diff --git a/tests/e2e/journey/input.ts b/tests/e2e/journey/input.ts index 77d5ce50..4497417b 100644 --- a/tests/e2e/journey/input.ts +++ b/tests/e2e/journey/input.ts @@ -73,6 +73,37 @@ export async function cdp( return output ? JSON.parse(output) : undefined; } +export type AccessibleName = Readonly<{ name: string; from?: string }>; + +type NameSource = Readonly<{ + type: string; + attribute?: string; + value?: { value?: string }; + superseded?: boolean; +}>; + +/** + * The name Chromium computes for the element `expression` evaluates to, which a screen reader + * announces, and what it took the name from, such as `aria-labelledby` or `contents`. + * Undefined when the expression evaluates to no element. + */ +export async function accessibleName( + obsidian: ObsidianClient, + expression: string, +): Promise { + const { result } = (await cdp(obsidian, "Runtime.evaluate", { expression })) as { + result: { objectId?: string }; + }; + if (!result.objectId) return undefined; + const { nodes } = (await cdp(obsidian, "Accessibility.getPartialAXTree", { + objectId: result.objectId, + fetchRelatives: false, + })) as { nodes: Array<{ name?: { value?: string; sources?: NameSource[] } }> }; + const { value = "", sources = [] } = nodes[0]?.name ?? {}; + const source = sources.find((candidate) => candidate.value?.value && !candidate.superseded); + return { name: value, from: source && (source.attribute ?? source.type) }; +} + export async function click( obsidian: ObsidianClient, target: UiTarget, diff --git a/tests/e2e/journey/targets.ts b/tests/e2e/journey/targets.ts index 349c9cc0..21f5c648 100644 --- a/tests/e2e/journey/targets.ts +++ b/tests/e2e/journey/targets.ts @@ -18,6 +18,7 @@ function byAttribute(name: string, selector: string, attribute: string, value: s }; } +const QUEUE = '[role="list"][aria-label="Queue"]'; export const PLAYER_VIEW = `.workspace-leaf-content[data-type="${VIEW_TYPE}"] .podcast-view`; function navItem(name: string, nav: string, label: string): UiTarget { @@ -194,11 +195,11 @@ export const ui = { offlineStrip: byCss("Offline strip", `${PLAYER_VIEW} .pn-strip[role="status"]`, "Offline"), queueRow: (title: string): UiTarget => ({ name: `queue row ${title}`, - elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-queue .pn-ep`)})].filter((el) => el.querySelector(".pn-ep-title")?.textContent.trim() === ${jsLiteral(title)})`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} ${QUEUE} .pn-ep`)})].filter((el) => el.querySelector(".pn-ep-title")?.textContent.trim() === ${jsLiteral(title)})`, }), queueGrip: (title: string): UiTarget => ({ name: `grip of ${title}`, - elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-queue button.pn-grip`)})].filter((el) => el.getAttribute("aria-label").startsWith(${jsLiteral(`Reorder ${title}, position `)}))`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} ${QUEUE} button.pn-grip`)})].filter((el) => el.getAttribute("aria-label").startsWith(${jsLiteral(`Reorder ${title}, position `)}))`, }), playlistRow: (name: string): UiTarget => ({ name: `playlist row ${name}`, diff --git a/tests/e2e/local-files-runtime.test.ts b/tests/e2e/local-files-runtime.test.ts index 1d8b4fcd..d18f5bef 100644 --- a/tests/e2e/local-files-runtime.test.ts +++ b/tests/e2e/local-files-runtime.test.ts @@ -64,7 +64,7 @@ describe("local files runtime", () => { ); await showList(obsidian, "Local files"); await clickInPodNotesView(obsidian, `[aria-label="More options for ${title}"]`); - const removalMenu = await clickMenuItem(obsidian, "Remove from Local Files"); + const removalMenu = await clickMenuItem(obsidian, "Remove from Local files"); expect(removalMenu).not.toContain("Remove download"); await plugin.waitForData( (data) => !inDownloads(data) && !inLocalFiles(data), @@ -205,7 +205,7 @@ describe("local files runtime", () => { ); await clickInPodNotesView(obsidian, `[aria-label="More options for ${legacy}"]`); - await clickMenuItem(obsidian, "Remove from Local Files"); + await clickMenuItem(obsidian, "Remove from Local files"); await plugin.waitForData( (data) => !inDownloads(data, "local file", legacy), WAIT_OPTS, diff --git a/tests/e2e/player-popout-save.test.ts b/tests/e2e/player-popout-save.test.ts index 31efb5ea..da591404 100644 --- a/tests/e2e/player-popout-save.test.ts +++ b/tests/e2e/player-popout-save.test.ts @@ -11,7 +11,7 @@ import { seedAudio, waitForPlaybackState, } from "./harness"; -import { reloadAfterPopout } from "./journey/actions"; +import { closePopoutAndReload, moveViewToPopout } from "./journey/actions"; import { jsLiteral, PLUGIN_ID, @@ -28,24 +28,6 @@ const PROGRESS_KEY = `local file::${EPISODE_TITLE}`; type Obsidian = Parameters[0]; type Order = "opened in a popout" | "moved to a popout while playing" | "in the main window"; -async function moveViewToPopout(obsidian: Obsidian): Promise { - expect( - await evalJsonAsync( - obsidian, - `(async () => { - const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; - app.workspace.moveLeafToPopout(leaf); - for (let i = 0; i < 50; i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - const moved = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; - if (moved?.view.containerEl.ownerDocument !== document) return true; - } - return false; - })()`, - ), - ).toBe(true); -} - async function play(order: Order): Promise { const { obsidian, plugin, sandbox } = getContext(); const audioPath = await seedAudio(obsidian, sandbox); @@ -107,28 +89,6 @@ async function reloadObsidian(obsidian: Obsidian): Promise { await waitForPodNotesReady(obsidian); } -// Closes a popout left open, saves the layout so no reload reopens it, and reloads: -// closing a popout leaves Obsidian's layout observers looping in the main window for a -// while, and a fresh window keeps later tests clean. -async function closePopoutAndReload(obsidian: Obsidian): Promise { - await evalJsonAsync( - obsidian, - `(async () => { - require("@electron/remote").getCurrentWindow().show(); - const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; - leaf?.detach(); - const windows = () => require("@electron/remote").BrowserWindow.getAllWindows().length; - for (let i = 0; i < 50 && (app.workspace.floatingSplit.children.length > 0 || windows() > 1); i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - app.workspace.requestSaveLayout.cancel(); - await app.workspace.saveLayout(); - return true; - })()`, - ); - await reloadAfterPopout(obsidian); -} - // The audio lives in the main window, so a popout holding the player is only a view of it. for (const order of ["opened in a popout", "moved to a popout while playing"] as const) { test(`closing a popout with a player ${order} keeps the audio playing and data.json intact`, async () => { diff --git a/tests/e2e/private-feeds.test.ts b/tests/e2e/private-feeds.test.ts index 862d036f..136b15b5 100644 --- a/tests/e2e/private-feeds.test.ts +++ b/tests/e2e/private-feeds.test.ts @@ -16,6 +16,7 @@ import { click, typeInto } from "./journey/input"; import { ui } from "./journey/targets"; import { eventually, + holdsFor, isDisabled, isShown, notices, @@ -24,6 +25,7 @@ import { } from "./journey/wait"; import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; import { + ageFeedCache, feedCacheHolds, feedCacheText, readLibrary, @@ -100,6 +102,67 @@ test("a private feed first opened offline explains that its address is not on th ).toBeFalsy(); }); +test("a private feed whose address is on this device, opened offline hours later, lists the episodes it cached and no missing address or earlier failure", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const show: FixtureFeed = { + title: "Private Kept", + slug: "private-kept", + query: "?auth=KEPT_SENTINEL", + artwork: false, + episodes: [{ title: "Kept episode", seconds: 6, pubDate: pubDate(0) }], + }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server); + const id = "podnotes-feed-url-48200000-0000-4000-8000-000000000001"; + await obsidian.dev.evalJson( + `app.secretStorage.setSecret(${jsLiteral(id)}, ${jsLiteral(server.feedUrl(show))}), true`, + ); + onTestFinished(async () => { + await obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(id)}, ""), true`); + await setOffline(obsidian, false); + }); + await plugin.updateDataAndReload>((data) => { + data.savedFeeds = { + [show.title]: { + title: show.title, + artworkUrl: "", + url: `podnotes-private-feed:${encodeURIComponent(show.title)}`, + urlSecretId: id, + }, + }; + }, RELOAD_OPTIONS); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(show.title)); + await waitUntilShown(obsidian, ui.episode("Kept episode")); + await obsidian.waitFor(() => feedCacheHolds(obsidian, "Kept episode"), { + timeoutMs: 10_000, + message: "the feed cached", + }); + server.disconnect(); + await click(obsidian, ui.refreshEpisodes); + await waitUntilShown(obsidian, ui.feedError); + await setOffline(obsidian, true); + await waitUntilShown(obsidian, ui.offlineStrip); + expect( + await holdsFor(obsidian, () => isShown(obsidian, ui.feedError), false), + "offline, the Offline strip says why the last load failed", + ).toBe(false); + await closePodNotes(obsidian); + await ageFeedCache(obsidian, 7 * 60 * 60 * 1000); + await plugin.reload(RELOAD_OPTIONS); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(show.title)); + await waitUntilShown(obsidian, ui.offlineStrip); + + expect(await isShown(obsidian, ui.episode("Kept episode"))).toBe(true); + expect(await holdsFor(obsidian, () => isShown(obsidian, ui.feedError), false)).toBe(false); + expect(await shownText(obsidian)).not.toContain("Address not on this device"); +}); + test("protects a Supercast subscription's address in data, cache keys and note links, then accepts it under the same reference", async () => { const context = getContext(); const { obsidian, plugin, sandbox } = context; diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index 1019bdfc..99f3797d 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -9,7 +9,7 @@ import { showPodNotes, } from "./journey/actions"; import { pubDate, startFixtureServer, type FixtureFeed } from "./journey/fixtures"; -import { click, pressKey, typeInto, typeText } from "./journey/input"; +import { accessibleName, click, pressKey, typeInto, typeText } from "./journey/input"; import { feedCacheHolds, recordWrites, seedLibrary } from "./journey/library"; import { PLAYER_VIEW, ui } from "./journey/targets"; import { eventually, waitUntilShown } from "./journey/wait"; @@ -64,7 +64,72 @@ async function settingValue(obsidian: ObsidianClient, name: string): Promise; + +/** + * The settings field that has focus, as a screen reader reads it: its row's name, and the name + * Chromium computes for it and where from. Undefined when focus is on anything else, such as + * a button. + */ +async function focusedField(obsidian: ObsidianClient): Promise { + const named = await accessibleName( + obsidian, + `document.activeElement.matches(".modal.mod-settings .setting-item-control :is(input, select, textarea, [tabindex='0'])") ? document.activeElement : null`, + ); + if (named === undefined) return undefined; + const row = await obsidian.dev.evalJson( + 'document.activeElement.closest(".setting-item").querySelector(".setting-item-name").textContent', + ); + return { row, ...named }; +} + describe("PodNotes settings tab", () => { + test("names each field by its row's name where Tab takes focus", async () => { + const { obsidian } = getContext(); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + // The diarization rows take focus only while diarization is on. + if ((await settingValue(obsidian, "Speaker diarization")) !== true) { + await click(obsidian, ui.settingControl("Speaker diarization", ".checkbox-container")); + } + await click(obsidian, ui.settingControl("Skip back (s)", "input")); + const fields: Field[] = []; + for (let presses = 0; presses < 60 && fields.at(-1)?.row !== "Export settings"; presses++) { + if (presses > 0) await pressKey(obsidian, "Tab"); + const field = await focusedField(obsidian); + if (field) fields.push(field); + } + + expect(fields.map(({ row }) => row)).toEqual([ + "Skip back (s)", + "Skip forward (s)", + "Default speed", + "Queue episodes I switch away from", + "Previous track", + "Next track", + "Timestamp template", + "Timestamp offset (s)", + "Episode note path", + "Episode note template", + "Podcast note path", + "Podcast note template", + "Transcript path", + "Transcript template", + "Speaker diarization", + "Diarization provider", + "Speaker label format", + "Download path", + "Export OPML", + "Latest episodes per podcast", + "Include API keys", + "Export settings", + ]); + // A focused toggle's label would also take the name of the checkbox inside it. + expect( + fields.filter(({ row, name, from }) => name !== row || from !== "aria-labelledby"), + ).toEqual([]); + }); + test("search finds a row of each section by name, and none of the removed rows", async () => { const { obsidian } = getContext(); await openPodNotesSettings(obsidian); diff --git a/tests/e2e/update-styles.test.ts b/tests/e2e/update-styles.test.ts new file mode 100644 index 00000000..885100af --- /dev/null +++ b/tests/e2e/update-styles.test.ts @@ -0,0 +1,195 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { + createLocalEpisode, + createPodNotesE2EHarness, + type PodNotesData, + seedAudio, +} from "./harness"; +import { closePopoutAndReload, moveViewToPopout, showPodNotes } from "./journey/actions"; +import { click } from "./journey/input"; +import { ui } from "./journey/targets"; +import { eventually, isShown, waitUntilShown } from "./journey/wait"; +import { jsLiteral, PLUGIN_ID, RELOAD_OPTIONS, VIEW_TYPE, waitForPodNotesReady } from "./runtime"; + +const getContext = createPodNotesE2EHarness("update-styles"); + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const RELEASE = "2.21.1"; +const RELEASE_DIGESTS = { + "main.js": "c1dc3560ad689e2df0d055209fb4d2a428c2233a348deedb62b9d87a7b9406ed", + "manifest.json": "d58d070821e14b699fbc929db0d086232ebd13acf47804a28c1e3f35715508b0", +} as const; +type Artifact = keyof typeof RELEASE_DIGESTS; +type Bundle = Record; +const ARTIFACTS = Object.keys(RELEASE_DIGESTS) as Artifact[]; + +// Stands for an earlier PodNotes sheet whose rule still matches the new markup. +const STALE_RULE = ".pn-mini .pn-iconbtn { color: rgb(255, 0, 0) !important; }"; +const OTHER_PLUGIN_SHEET = "svelte-e2e-other"; + +type Where = "main" | "view"; +type ButtonStyle = Record<"color" | "stroke" | "fill" | "opacity" | "size" | "background", string>; + +const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + +/** The released bundle from its GitHub release, cached in the git-ignored artifacts folder. */ +async function releasedBundle(): Promise { + const dir = path.join(ROOT, ".obsidian-e2e-artifacts/releases", RELEASE); + await mkdir(dir, { recursive: true }); + const bundle = {} as Bundle; + for (const name of ARTIFACTS) { + const file = path.join(dir, name); + let bytes = await readFile(file).catch(() => null); + if (!bytes || sha256(bytes) !== RELEASE_DIGESTS[name]) { + const url = `https://github.com/chhoumann/PodNotes/releases/download/${RELEASE}/${name}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} answered ${response.status}.`); + bytes = Buffer.from(await response.arrayBuffer()); + expect(sha256(bytes), `the ${RELEASE} ${name} digest`).toBe(RELEASE_DIGESTS[name]); + await writeFile(file, bytes); + } + bundle[name] = bytes; + } + return bundle; +} + +async function pluginDir(): Promise { + const { obsidian } = getContext(); + const dir = await obsidian.dev.evalJson( + `app.plugins.manifests[${jsLiteral(PLUGIN_ID)}].dir`, + ); + return path.join(await obsidian.vaultPath(), dir); +} + +/** Writes `bundle` over the plugin's files and reloads it the way Obsidian's updater does. */ +async function install(dir: string, bundle: Bundle): Promise { + const { obsidian } = getContext(); + for (const name of ARTIFACTS) { + await rm(path.join(dir, name)); + await writeFile(path.join(dir, name), bundle[name]); + } + await obsidian.dev.evalJsonAsync(`(async () => { + const plugins = app.plugins; + await plugins.loadManifest(plugins.manifests[${jsLiteral(PLUGIN_ID)}].dir); + await plugins.disablePlugin(${jsLiteral(PLUGIN_ID)}); + await plugins.enablePlugin(${jsLiteral(PLUGIN_ID)}, true); + return true; + })()`); + await waitForPodNotesReady(obsidian); +} + +const viewDocument = `app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0].view.containerEl.ownerDocument`; +const documentOf = (where: Where) => (where === "main" ? "document" : viewDocument); + +async function plant(where: Where, id: string, css: string): Promise { + await getContext().obsidian.dev.evalJson(`(() => { + const doc = ${documentOf(where)}; + const style = doc.createElement("style"); + style.id = ${jsLiteral(id)}; + style.textContent = ${jsLiteral(css)}; + doc.head.append(style); + return true; + })()`); +} + +/** Ids of the PodNotes and test sheets in a document's head, in order, repeats kept. */ +function sheetIds(where: Where): Promise { + return getContext().obsidian.dev.evalJson( + `[...(${documentOf(where)}).head.querySelectorAll("style[id]")].map((style) => style.id).filter((id) => /^(pn-|svelte-e2e-)/.test(id))`, + ); +} + +/** The mini player's Play button as the view's window draws it. */ +function playButton(): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const doc = ${viewDocument}; + const button = doc.querySelector(".pn-mini button[aria-label=Play]"); + if (!button) return null; + const style = doc.defaultView.getComputedStyle(button); + const svg = doc.defaultView.getComputedStyle(button.querySelector("svg")); + return { + color: style.color, + stroke: svg.stroke, + fill: svg.fill, + opacity: style.opacity, + size: style.width + " " + style.height, + background: style.backgroundColor, + }; + })()`); +} + +/** Shows the mini player with a local episode in the main window, and reads its Play button. */ +async function showMiniPlayer(): Promise { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(obsidian, sandbox); + await plugin.updateDataAndReload((data) => { + data.currentEpisode = createLocalEpisode("E2E Update Styles", audioPath); + }, RELOAD_OPTIONS); + await waitForPodNotesReady(obsidian); + await showPodNotes(obsidian); + if (await isShown(obsidian, ui.fullPlayer)) await click(obsidian, ui.collapsePlayer); + await waitUntilShown(obsidian, ui.miniPlayer); + const play = await playButton(); + expect(play, "the mini player's Play button").not.toBeNull(); + return play!; +} + +// A user runs the released version with the view where they left it, then updates. +test.each(["main window", "popout"] as const)( + "updating PodNotes in place drops the previous bundle's sheets with the view in the %s", + async (placement) => { + const { obsidian, plugin } = getContext(); + const released = await releasedBundle(); + const dir = await pluginDir(); + const links = {} as Record; + const current = {} as Bundle; + for (const name of ARTIFACTS) { + links[name] = await readlink(path.join(dir, name)); + current[name] = await readFile(path.resolve(dir, links[name])); + } + const fresh = await showMiniPlayer(); + try { + await install(dir, released); + if (placement === "popout") await moveViewToPopout(obsidian); + await plant("main", "pn-e2e-stale-main", STALE_RULE); + await plant("view", "pn-e2e-stale-view", STALE_RULE); + await plant("main", OTHER_PLUGIN_SHEET, ""); + await plant("view", OTHER_PLUGIN_SHEET, ""); + + await install(dir, current); + + const sheets = async () => { + const ids = [await sheetIds("main"), await sheetIds("view")]; + return { + stale: ids.flat().filter((id) => id.startsWith("pn-e2e-stale-")), + repeated: ids.flatMap((list) => + list.filter( + (id, index) => id.startsWith("pn-") && list.indexOf(id) !== index, + ), + ), + other: ids.map((list) => list.includes(OTHER_PLUGIN_SHEET)), + }; + }; + const clean = { stale: [], repeated: [], other: [true, true] }; + expect(await eventually(obsidian, sheets, clean)).toEqual(clean); + expect(await eventually(obsidian, playButton, fresh)).toEqual(fresh); + expect( + await obsidian.dev.evalJson(`(${viewDocument}) !== document`), + "the view's window", + ).toBe(placement === "popout"); + } finally { + // An install that failed part way may have removed an artifact already. + for (const name of ARTIFACTS) { + await rm(path.join(dir, name), { force: true }); + await symlink(links[name], path.join(dir, name)); + } + if (placement === "popout") await closePopoutAndReload(obsidian); + else await plugin.reload(RELOAD_OPTIONS); + } + }, + 180_000, +); diff --git a/tests/e2e/view-polish.test.ts b/tests/e2e/view-polish.test.ts new file mode 100644 index 00000000..18b3621f --- /dev/null +++ b/tests/e2e/view-polish.test.ts @@ -0,0 +1,224 @@ +import { expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { + asPhone, + placePodNotes, + setOffline, + showLibrary, + showList, + showPodNotes, +} from "./journey/actions"; +import { click, pressKey } from "./journey/input"; +import { PLAYER_VIEW, ui } from "./journey/targets"; +import { eventually, isShown, waitUntilShown } from "./journey/wait"; +import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; +import { seedLibrary } from "./journey/library"; +import { jsLiteral, RELOAD_OPTIONS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("view-polish"); + +const LONG: FixtureFeed = { + title: "The Remarkably Long Podcast Title That Keeps Going", + slug: "long-title", + artwork: false, + episodes: [{ title: "First Steps", seconds: 60, pubDate: pubDate(0) }], +}; +const PRIVATE = "Missing Address"; +const SECRET_ID = "podnotes-feed-url-4b0c7e1a-2f6d-4c3b-9a8e-5d1f0e2c7b9a"; + +async function atSidebarWidth(width: number): Promise { + const { obsidian } = getContext(); + await obsidian.dev.evalJson(`app.workspace.rightSplit.setSize(${width}), true`); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `Math.round(document.querySelector(${jsLiteral(PLAYER_VIEW)}).getBoundingClientRect().width) === ${width}`, + ), + { timeoutMs: 5_000, intervalMs: 100, message: `the view ${width} px wide` }, + ); +} + +type CoverText = Readonly<{ + size: number; + firstInside: boolean; + bottomGap: number; + fontSize: number; + lines: number; +}>; + +/** Where a text cover draws its title's first letter and its last line, and how many lines show. */ +function coverText(selector: string): Promise { + return getContext().obsidian.dev.evalJson(`(() => + [...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} ${selector} .pn-cover.is-text`)})].map((cover) => { + const box = cover.getBoundingClientRect(); + const text = cover.firstElementChild; + const range = document.createRange(); + range.setStart(text.firstChild, 0); + range.setEnd(text.firstChild, 1); + const first = range.getBoundingClientRect(); + range.selectNodeContents(text); + const shown = text.getBoundingClientRect(); + const bottom = Math.min(shown.bottom, box.bottom); + const lines = new Set( + [...range.getClientRects()] + .map((line) => Math.round((line.top + line.bottom) / 2)) + .filter((middle) => middle > shown.top && middle < bottom), + ); + return { + size: Math.round(box.width), + firstInside: first.top >= box.top && first.bottom <= box.bottom && first.left >= box.left, + bottomGap: Math.round(box.bottom - text.getBoundingClientRect().bottom), + fontSize: parseFloat(getComputedStyle(text).fontSize), + lines: lines.size, + }; + }))()`); +} + +test("at 320 px, a podcast without artwork shows the start of its long name in three lines on its card and rows", async () => { + const context = getContext(); + const server = await startFixtureServer([LONG]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [LONG] }); + await showPodNotes(context.obsidian); + await atSidebarWidth(320); + await showLibrary(context.obsidian); + + const [card] = await coverText(".pn-gridcard"); + expect(card).toMatchObject({ + firstInside: true, + bottomGap: Math.round(card.size / 10), + lines: 3, + }); + expect(card.fontSize).toBeGreaterThanOrEqual(10); + + await showList(context.obsidian, "Latest episodes"); + await waitUntilShown(context.obsidian, ui.episode("First Steps")); + const [row] = await coverText(".pn-ep-main"); + expect(row).toMatchObject({ + size: 40, + firstInside: true, + bottomGap: 4, + fontSize: 10, + lines: 3, + }); +}); + +type Look = Readonly<{ opacity: number; dimmedBy: number }>; + +/** A control's own opacity, and the opacity of everything it is drawn in. */ +function look(selector: string, text?: string): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const el = [...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} ${selector}`)})].find( + (el) => ${jsLiteral(text ?? null)} === null || el.textContent.trim() === ${jsLiteral(text ?? "")}, + ); + let dimmedBy = 1; + for (let at = el.parentElement; at; at = at.parentElement) dimmedBy *= Number(getComputedStyle(at).opacity); + return { opacity: Number(getComputedStyle(el).opacity), dimmedBy }; + })()`); +} + +test("a feed address missing from this device wraps in the banner, and offline every disabled control looks alike", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await startFixtureServer([LONG]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [LONG] }); + await obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(SECRET_ID)}, ""), true`); + await plugin.updateDataAndReload>>((data) => { + data.savedFeeds[PRIVATE] = { + title: PRIVATE, + artworkUrl: "", + url: `podnotes-private-feed:${encodeURIComponent(PRIVATE)}`, + urlSecretId: SECRET_ID, + }; + }, RELOAD_OPTIONS); + await showPodNotes(obsidian); + await atSidebarWidth(320); + await showList(obsidian, "Latest episodes"); + await waitUntilShown(obsidian, ui.episode("First Steps")); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(PRIVATE)); + await waitUntilShown(obsidian, ui.feedErrorAction("Set feed address")); + + const detail = await obsidian.dev.evalJson<{ text: string; clipped: boolean }>(`(() => { + const detail = document.querySelector(${jsLiteral(`${PLAYER_VIEW} .pn-banner .d`)}); + return { text: detail.textContent, clipped: detail.scrollWidth > detail.clientWidth }; + })()`); + expect(detail).toEqual({ text: "Address not on this device", clipped: false }); + + await setOffline(obsidian, true); + onTestFinished(() => setOffline(obsidian, false)); + expect( + await eventually( + obsidian, + async () => [ + await look(".pn-banner button", "Set feed address"), + await look('button[aria-label="Refresh episodes"]'), + ], + [ + { opacity: 0.4, dimmedBy: 1 }, + { opacity: 0.4, dimmedBy: 1 }, + ], + ), + ).toEqual([ + { opacity: 0.4, dimmedBy: 1 }, + { opacity: 0.4, dimmedBy: 1 }, + ]); + + await click(obsidian, ui.back); + await showList(obsidian, "Latest episodes"); + expect(await look('.pn-ep-main[aria-disabled="true"]')).toEqual({ opacity: 0.4, dimmedBy: 1 }); +}); + +test("on a phone, the Escape that closes a row's menu in the right drawer leaves focus on the row's More button", async () => { + const context = getContext(); + const { obsidian } = context; + await asPhone(obsidian); + const server = await startFixtureServer([LONG]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [LONG] }); + // Obsidian sends focus from a sidebar to the main area on an Escape nothing handled. + await placePodNotes(obsidian, "right drawer"); + await showList(obsidian, "Latest episodes"); + await click(obsidian, ui.episodeMenu("First Steps")); + await waitUntilShown(obsidian, ui.menuItem("Add to queue")); + // Obsidian gives a menu the keys a timer tick after it shows. + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + '(app.keymap.getWindowStack(activeWindow).scope.keys ?? []).some((key) => key.key === "ArrowDown")', + ), + { timeoutMs: 5_000, intervalMs: 50, message: "the menu to take the keys" }, + ); + // Where focus is in the task after the Escape, and 200 ms later, past the menu's 150 ms hide. + await obsidian.dev.evalJson(`(() => { + const more = (${ui.episodeMenu("First Steps").elements})[0]; + const view = document.querySelector(${jsLiteral(PLAYER_VIEW)}); + const at = () => ({ onMore: document.activeElement === more, inView: view.contains(document.activeElement) }); + window.pnEscapeFocus = []; + activeWindow.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + setTimeout(() => window.pnEscapeFocus.push(at())); + setTimeout(() => window.pnEscapeFocus.push(at()), 200); + }, { capture: true, once: true }); + return true; + })()`); + onTestFinished(async () => { + await obsidian.dev.evalJson("delete window.pnEscapeFocus"); + }); + + await pressKey(obsidian, "Escape"); + + expect( + await eventually(obsidian, () => obsidian.dev.evalJson("window.pnEscapeFocus"), [ + { onMore: true, inView: true }, + { onMore: true, inView: true }, + ]), + ).toEqual([ + { onMore: true, inView: true }, + { onMore: true, inView: true }, + ]); + expect( + await eventually(obsidian, () => isShown(obsidian, ui.menuItem("Add to queue")), false), + ).toBe(false); +}); diff --git a/tests/mocks/componentCss.ts b/tests/mocks/componentCss.ts new file mode 100644 index 00000000..2e090fa9 --- /dev/null +++ b/tests/mocks/componentCss.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import postcss, { type AtRule, type ChildNode, type Container } from "postcss"; +import { compile } from "svelte/compiler"; + +const COMPONENTS = path.resolve(import.meta.dirname, "../../src/ui/PodcastView"); + +/** + * The CSS of the view's components as jsdom cascades it. Each component is scoped by the + * class `pn-`, so markup takes that class where its own sheet scopes an element. + * Nesting is flattened, which jsdom lacks, and sheets keep the order given: shell first, as + * it mounts first. jsdom applies no media or container query but `all`, so `media` names the + * queries that match, as the user's device, setting or the container's size would make them. + * `states` turns pseudo-classes such as `:focus-visible` into classes a test can set. + */ +export function componentCss( + files: readonly string[], + { media = [], states = [] }: { media?: string[]; states?: string[] } = {}, +): string { + const out = postcss.root(); + const visit = (node: ChildNode, selectors: string[] | null, within: Container) => { + if (node.type === "rule") { + const own = selectors + ? selectors.flatMap((outer) => + node.selectors.map((inner) => + inner.includes("&") + ? inner.replaceAll("&", outer) + : `${outer} ${inner}`, + ), + ) + : node.selectors; + const declarations = node.nodes.filter((child) => child.type === "decl"); + if (declarations.length > 0) + within.append( + postcss.rule({ selectors: own, nodes: declarations.map((d) => d.clone()) }), + ); + for (const child of node.nodes) if (child.type !== "decl") visit(child, own, within); + } else if (node.type === "atrule" && (node.name === "media" || node.name === "container")) { + const query = media.includes(node.params) ? "all" : node.params; + const copy = postcss.atRule({ name: "media", params: query }); + within.append(copy); + for (const child of (node as AtRule).nodes ?? []) visit(child, selectors, copy); + } else if (node.type === "atrule") within.append(node.clone()); + }; + for (const file of files) { + const source = readFileSync(path.join(COMPONENTS, `${file}.svelte`), "utf8"); + const { css } = compile(source, { css: "external", cssHash: () => `pn-${file}` }); + postcss.parse(css?.code ?? "").each((node) => visit(node, null, out)); + } + let text = out.toString(); + for (const state of states) text = text.replaceAll(`:${state}`, `.${state}`); + return text; +} + +/** Puts `css` and `html` in the test document, and returns the element `selector` finds. */ +export function styled(css: string, html: string, selector: string): HTMLElement { + const style = document.createElement("style"); + style.textContent = css; + document.head.replaceChildren(style); + document.body.innerHTML = html; + return document.querySelector(selector)!; +} diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index d60d02d6..3784e0da 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -124,6 +124,8 @@ export class Workspace { } export class App { + /** No file: a test that needs one puts its own vault in. */ + vault = { getFileByPath: (_path: string): TFile | null => null }; secretStorage = new SecretStorage(); scope = new Scope(); workspace = new Workspace(); @@ -413,18 +415,24 @@ export class DropdownComponent extends BaseInteractiveElement { } /** As Obsidian's: setValue runs the change callback when the value changes. */ +/** Obsidian 1.14's markup: the focusable label holds a checkbox that takes no focus. */ export class ToggleComponent extends BaseInteractiveElement { private on = false; private changeCallback?: (on: boolean) => void; + private readonly inputEl = document.createElement("input"); constructor(container: HTMLElement) { - super("input", container); - this.toggleEl.type = "checkbox"; - this.toggleEl.addEventListener("change", () => this.setValue(this.toggleEl.checked)); + super("label", container); + this.toggleEl.className = "checkbox-container"; + this.toggleEl.tabIndex = 0; + this.inputEl.type = "checkbox"; + this.inputEl.tabIndex = -1; + this.toggleEl.appendChild(this.inputEl); + this.inputEl.addEventListener("change", () => this.setValue(this.inputEl.checked)); } get toggleEl() { - return this.element as HTMLInputElement; + return this.element as HTMLLabelElement; } getValue() { @@ -432,7 +440,7 @@ export class ToggleComponent extends BaseInteractiveElement { } setValue(on: boolean) { - this.toggleEl.checked = on; + this.inputEl.checked = on; if (this.on !== on) { this.on = on; this.changeCallback?.(on); @@ -441,7 +449,8 @@ export class ToggleComponent extends BaseInteractiveElement { } setDisabled(disabled: boolean) { - this.toggleEl.disabled = disabled; + this.inputEl.disabled = disabled; + this.toggleEl.classList.toggle("is-disabled", disabled); return this; } @@ -726,6 +735,8 @@ export class MenuItem { export class Menu { /** Test only: the menu on screen, until it hides. */ static shown: Menu | null = null; + /** Test only: as on a phone, where Obsidian runs the hide callback after an animation. */ + static hideDelayMs = 0; items: Array = []; private hideCallback: (() => unknown) | null = null; @@ -759,7 +770,8 @@ export class Menu { if (Menu.shown === this) Menu.shown = null; const callback = this.hideCallback; this.hideCallback = null; - callback?.(); + if (Menu.hideDelayMs > 0) setTimeout(() => callback?.(), Menu.hideDelayMs); + else callback?.(); return this; } diff --git a/tests/mocks/panelLayout.ts b/tests/mocks/panelLayout.ts new file mode 100644 index 00000000..1802cc8d --- /dev/null +++ b/tests/mocks/panelLayout.ts @@ -0,0 +1,49 @@ +import { vi } from "vitest"; + +/** + * Lays a player panel's list out as the browser would: `height` px tall, with Panel's faded + * edges, 12 px at the top and 20 px at the bottom, as its scroll padding, and each row 40 px + * tall or as `tall` sets it by index. Its scroll position stays within the rows, and a change + * to it fires `scroll` soon after. Returns a resize to `height`, which clamps the scroll + * position to the rows left. + */ +export function layOut( + list: HTMLElement, + { height = 120, tall = {} }: { height?: number; tall?: Record } = {}, +): (height: number) => void { + let top = 0; + const rowHeight = (index: number) => tall[index] ?? 40; + const rowTop = (index: number) => + Array.from({ length: index }, (_, at) => rowHeight(at)).reduce((sum, h) => sum + h, 0); + const scrollTo = (value: number) => { + const next = Math.max(0, Math.min(value, list.scrollHeight - height)); + if (next === top) return; + top = next; + queueMicrotask(() => list.dispatchEvent(new Event("scroll"))); + }; + list.style.setProperty("scroll-padding-top", "12px"); + list.style.setProperty("scroll-padding-bottom", "20px"); + Object.defineProperties(list, { + clientHeight: { configurable: true, get: () => height }, + scrollHeight: { configurable: true, get: () => rowTop(list.children.length) }, + scrollTop: { configurable: true, get: () => top, set: scrollTo }, + }); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + const index = [...list.children].indexOf(this); + const y = this === list ? 0 : rowTop(index) - top; + const h = this === list ? height : rowHeight(index); + return { top: y, bottom: y + h, height: h, left: 0, right: 300 } as DOMRect; + }); + return (to) => { + height = to; + scrollTo(top); + }; +} + +/** Where the row marked current starts, from the top of its list. */ +export function currentRowTop(list: HTMLElement): number { + const row = list.querySelector('[aria-current="true"]')!.closest("li")!; + return row.getBoundingClientRect().top - list.getBoundingClientRect().top; +} diff --git a/tests/tooling/componentStyles.test.ts b/tests/tooling/componentStyles.test.ts new file mode 100644 index 00000000..30eb951c --- /dev/null +++ b/tests/tooling/componentStyles.test.ts @@ -0,0 +1,133 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type AST, parse } from "svelte/compiler"; +import { describe, expect, test } from "vitest"; + +type Sources = Readonly>; +type Selector = AST.CSS.SimpleSelector | AST.CSS.RelativeSelector; + +const UI = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../src/ui"); + +function components(): Sources { + return Object.fromEntries( + readdirSync(UI, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".svelte")) + .map((file) => [file, readFileSync(path.join(UI, file), "utf8")]), + ); +} + +/** + * The view's classes a component's sheet scopes to itself. A class under `:global(...)`, + * or after a bare `:global`, is a reference to another component's element instead. + */ +function ownedClasses(file: string, source: string): Set { + const owned = new Set(); + const visitList = (list: AST.CSS.SelectorList, global: boolean): boolean => { + let opensGlobal = false; + for (const complex of list.children) { + let inGlobal = global; + for (const relative of complex.children) { + for (const simple of relative.selectors as Selector[]) { + if ( + simple.type === "ClassSelector" && + !inGlobal && + /^(pn-|podcast-view$)/.test(simple.name) + ) + owned.add(simple.name); + if (simple.type !== "PseudoClassSelector") continue; + if (simple.name === "global" && !simple.args) inGlobal = opensGlobal = true; + else if (simple.args) + visitList(simple.args, inGlobal || simple.name === "global"); + } + } + } + return opensGlobal; + }; + const visitBlock = (nodes: AST.CSS.Block["children"], global: boolean) => { + for (const node of nodes) { + if (node.type === "Rule") + visitBlock(node.block.children, visitList(node.prelude, global) || global); + else if (node.type === "Atrule" && node.block) visitBlock(node.block.children, global); + } + }; + const ast = parse(source, { filename: file, modern: true }); + if (ast.css) visitBlock(ast.css.children, false); + return owned; +} + +/** Classes that more than one component scopes to itself, with those components. */ +function sharedOwners(sources: Sources): Record { + const owners = new Map(); + for (const [file, source] of Object.entries(sources)) { + for (const name of ownedClasses(file, source)) + owners.set(name, [...(owners.get(name) ?? []), file]); + } + return Object.fromEntries([...owners].filter(([, files]) => files.length > 1)); +} + +/** Elements with two tooltips: Obsidian shows an aria-label as one, and a title is another. */ +function doubleTooltips(sources: Sources): string[] { + const found: string[] = []; + const visit = (node: unknown, file: string): void => { + if (!node || typeof node !== "object") return; + const { type, name, attributes } = node as { + type?: string; + name?: string; + attributes?: Array<{ name?: string }>; + }; + const names = attributes?.map((attribute) => attribute.name) ?? []; + if ( + (type === "RegularElement" || type === "SvelteElement") && + names.includes("aria-label") && + names.includes("title") + ) + found.push(`${file} <${name}>`); + for (const [key, value] of Object.entries(node)) if (key !== "metadata") visit(value, file); + }; + for (const [file, source] of Object.entries(sources)) + visit(parse(source, { filename: file, modern: true }).fragment, file); + return found; +} + +describe("component style sheets", () => { + test("each of the view's classes is scoped by one component at most", () => { + expect(sharedOwners(components())).toEqual({}); + }); + + test("finds a class two components scope, also inside a media query or :is()", () => { + expect( + sharedOwners({ + "A.svelte": `
`, + "B.svelte": ``, + "C.svelte": ``, + }), + ).toEqual({ "pn-a": ["A.svelte", "B.svelte"], "pn-b": ["B.svelte", "C.svelte"] }); + }); + + test("lets other components refer to a class through :global", () => { + expect( + sharedOwners({ + "A.svelte": `
`, + "B.svelte": `
`, + "C.svelte": `
`, + }), + ).toEqual({}); + }); +}); + +describe("tooltips", () => { + test("no element has both an aria-label and a title", () => { + expect(doubleTooltips(components())).toEqual([]); + }); + + test("finds an element with both, also in a block or a snippet, and not a component's props", () => { + expect( + doubleTooltips({ + "A.svelte": ``, + "B.svelte": `{#if on}{#snippet row()}{/snippet}{/if}`, + "C.svelte": ``, + }), + ).toEqual(["A.svelte