Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,25 @@ describe("capture", () => {

expect(editor.getValue()).toBe("Notes\n");
});

it("falls back to the note editor when the cell's replaceSelection is not a function", async () => {
const { plugin, openNote, focusPodNotes } = await loadPlugin(playing);
const { editor } = openNote("Table.md", TABLE, TABLE.indexOf("anchor") + 6);
Object.assign(editor, {
editorComponent: { tableCell: { editor: { replaceSelection: "unavailable" } } },
});
focusPodNotes();

expect(await plugin.capture.timestamp()).toEqual({
destination: "editor",
path: "Table.md",
text: "- 00:20:34",
created: false,
});
expect(editor.getValue()).toBe(
"| Time | Note |\n| --- | --- |\n| Start | anchor- 00:20:34 |\nAfter",
);
});
});

it("goes to the episode note once the last editor's tab is closed", async () => {
Expand Down
19 changes: 19 additions & 0 deletions src/main.persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as obsidian from "obsidian";
import { DEFAULT_SETTINGS } from "./constants";
import PodNotes from "./main";
import { encodePodNotesData } from "./persistence/podNotesData";
import * as podNotesData from "./persistence/podNotesData";
import { PlayerController } from "./player/PlayerController";
import { downloadedEpisodes, localFiles, plugin as pluginStore } from "./store";
import { bindStoresToSettings } from "./store/persistence";
Expand Down Expand Up @@ -85,6 +86,24 @@ afterEach(() => {
vi.restoreAllMocks();
});

it("does not decode its own saved document again during a settings session", async () => {
const plugin = makePlugin();
const decode = vi.spyOn(podNotesData, "decodePodNotesData");
await plugin.loadSettings();
expect(decode).toHaveBeenCalledTimes(1);
decode.mockClear();

for (const seconds of [20, 30, 45]) {
plugin.settings.skipBackwardLength = seconds;
await plugin.saveSettingsStrict();
await plugin.onExternalSettingsChange();
expect(await plugin.loadData()).toMatchObject({ skipBackwardLength: seconds });
}

expect(plugin.settings.skipBackwardLength).toBe(45);
expect(decode).toHaveBeenCalledTimes(0);
});

describe("save refusal recovery", () => {
function recovering() {
vi.spyOn(console, "error").mockImplementation(() => undefined);
Expand Down
26 changes: 26 additions & 0 deletions src/services/publishedTranscript/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,32 @@ describe("findPublishedTranscript", () => {
describe("fetchPublishedTranscript", () => {
const signal = new AbortController().signal;

test("preserves the quoted feed and transcript URLs of a public source", async () => {
const { feeds } = repository([]);
fetchTextMock.mockResolvedValue(
"WEBVTT\n\n00:00:00.000 --> 00:00:05.000\n<v Host>Read https://show.test/feed.xml and https://show.test/transcripts/1.vtt for more.\n",
);

expect(
await fetchPublishedTranscript(
feeds,
episodeOf(show),
{ key: "Show", reference: "transcripts/1.vtt", format: "vtt" },
signal,
),
).toEqual({
kind: "timed",
cues: [
{
start: 0,
end: 5,
speaker: "Host",
text: "Read https://show.test/feed.xml and https://show.test/transcripts/1.vtt for more.",
},
],
});
});

test("fetches the link resolved against the feed, once, within the transcript limits", async () => {
const { feeds } = repository([]);
fetchTextMock.mockResolvedValue(transcriptFixture("build-your-saas.vtt"));
Expand Down
84 changes: 84 additions & 0 deletions tests/e2e/journey-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,90 @@ const focused = (target: UiTarget) =>
);

describe("player journeys", () => {
test("the fullscreen button belongs to the video stage and hides while that stage is fullscreen", 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);
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<boolean>(
`document.fullscreenElement === document.querySelector(${jsLiteral(`${PLAYER_VIEW} .pn-video`)})`,
),
true,
),
).toBe(true);
expect(
await obsidian.dev.evalJson<string>(
`getComputedStyle((${fullscreen.elements})[0]).display`,
),
).toBe("none");

await pressKey(obsidian, "Escape");
expect(
await eventually(
obsidian,
() => obsidian.dev.evalJson<boolean>("document.fullscreenElement === null"),
true,
),
).toBe(true);
});

test.each(["theme-light", "theme-dark"])(
"mini-player component styles survive the shell stylesheet loading last in %s",
async (theme) => {
const { obsidian } = getContext();
await openPlayer([]);
await resizeView(320, "compact");
await click(obsidian, ui.collapsePlayer);
await waitUntilShown(obsidian, ui.miniPlayer);
await cdp(obsidian, "Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 });
const styles = await obsidian.dev.evalJson<{
before: Record<string, string>[];
after: Record<string, string>[];
}>(`(() => {
const root = document.querySelector(${jsLiteral(PLAYER_VIEW)});
const sheet = [...document.head.querySelectorAll("style")].find(sheet => root.classList.contains(sheet.id));
if (!sheet) throw new Error("The shell stylesheet is missing");
const next = sheet.nextSibling;
const originalTheme = document.body.className;
const read = () => [...root.querySelectorAll(".pn-mini button.pn-iconbtn")].map(button => {
const style = getComputedStyle(button);
return Object.fromEntries(["color", "opacity", "height", "width", "background-color", "border-radius"].map(key => [key, style.getPropertyValue(key)]));
});
try {
document.body.classList.remove("theme-light", "theme-dark");
document.body.classList.add(${jsLiteral(theme)});
document.head.prepend(sheet);
const before = read();
document.head.append(sheet);
return { before, after: read() };
} finally {
document.head.insertBefore(sheet, next);
document.body.className = originalTheme;
}
})()`);

expect(styles.before).toHaveLength(2);
expect(styles.before.map((style) => style.opacity)).toEqual(["1", "1"]);
expect(styles.after).toEqual(styles.before);
},
);

test("a video with a long queue keeps its 16:9 frame, compact and wide, and after a panel covered it", async () => {
const { obsidian } = getContext();
await openPlayer(QUEUED, () => ({ mediaType: "video" }));
Expand Down
55 changes: 55 additions & 0 deletions tests/e2e/journey-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,61 @@ async function takeNote(): Promise<string> {
}

describe("transcript journeys", () => {
test("switching episodes clears Find and shows every line of the incoming transcript", async () => {
const context = getContext();
const { obsidian } = context;
const incoming: FixtureEpisode = {
title: "Second Transcript",
seconds: 60,
pubDate: pubDate(2),
transcripts: [{ path: "2.vtt", type: "text/vtt", body: VTT }],
};
const feed = { ...FEED, episodes: [...FEED.episodes, incoming] };
const server = await startFixtureServer([feed]);
onTestFinished(() => server.stop());
await seedLibrary(context, server, {
subscriptions: [feed],
current: storedEpisode(server, feed, CAPTIONED),
queue: [storedEpisode(server, feed, incoming)],
});
await showPodNotes(obsidian);
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),
).toEqual(LINES);
await click(obsidian, panelButton("Find in transcript"));
await typeText(obsidian, "palace");
expect(
await eventually(obsidian, async () => (await shownTranscript()).marks, [
"palace",
"palace",
"Palace",
]),
).toEqual(["palace", "palace", "Palace"]);

await obsidian.dev.evalJson(
`app.plugins.plugins.podnotes.player.play(${jsLiteral(storedEpisode(server, feed, incoming))}), true`,
);
await waitForPlayback(
obsidian,
(state) => state.episode?.title === incoming.title && state.playing,
"incoming episode playing",
);
expect(
await eventually(obsidian, shownTranscript, {
lines: LINES,
current: LINES[0],
hit: null,
marks: [],
}),
).toEqual({ lines: LINES, current: LINES[0], hit: null, marks: [] });
expect(await isShown(obsidian, findField)).toBe(false);
await click(obsidian, panelButton("Find in transcript"));
expect(await obsidian.dev.evalJson(`(${findField.elements})[0].value`)).toBe("");
});

test("the feed's transcript follows playback, seeks where tapped, finds a word, and saves the command's note", async () => {
const { obsidian } = getContext();
await openTranscript(CAPTIONED);
Expand Down
106 changes: 106 additions & 0 deletions tests/e2e/playback-media-reset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, onTestFinished, test } from "vitest";
import { createPodNotesE2EHarness, type PodNotesData } from "./harness";
import { showLibrary, showPodNotes } from "./journey/actions";
import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures";
import { click } from "./journey/input";
import { seedLibrary, storedEpisode, waitForPlayback } from "./journey/library";
import { ui } from "./journey/targets";
import { jsLiteral, WAIT_OPTS } from "./runtime";

const getContext = createPodNotesE2EHarness("playback-media-reset");
const FEED: FixtureFeed = {
title: "Media reset",
slug: "media-reset",
artwork: true,
episodes: [
{ title: "Outgoing", seconds: 60, pubDate: pubDate(0) },
{ title: "Incoming", seconds: 60, pubDate: pubDate(1) },
],
};

async function startOutgoing() {
const context = getContext();
const server = await startFixtureServer([FEED]);
onTestFinished(() => server.stop());
await seedLibrary(context, server, { subscriptions: [FEED] });
await showPodNotes(context.obsidian);
await showLibrary(context.obsidian);
await click(context.obsidian, ui.podcastCard(FEED.title));
await click(context.obsidian, ui.episode("Outgoing"));
await waitForPlayback(
context.obsidian,
(state) => state.playing && state.seconds > 0,
"playing",
);
return { ...context, incoming: storedEpisode(server, FEED, FEED.episodes[1]) };
}

for (const method of ["resume", "play"]) {
test(`${method} preserves 12 seconds before a reset element reports emptied`, async () => {
const { obsidian } = await startOutgoing();
await obsidian.dev.evalJson(
"app.plugins.plugins.podnotes.api.stop(), app.plugins.plugins.podnotes.api.currentTime = 12, true",
);
await waitForPlayback(
obsidian,
(state) => !state.playing && state.seconds === 12,
"paused at 12",
);
const immediate = await obsidian.dev.evalJson<{
before: number;
ready: number;
reset: number;
}>(`(() => {
const plugin = app.plugins.plugins.podnotes;
const media = document.querySelector(".podnotes-media");
const before = media.currentTime;
media.load();
const ready = media.readyState, reset = media.currentTime;
${method === "resume" ? "plugin.player.resume()" : "plugin.player.play(plugin.api.podcast)"};
return { before, ready, reset };
})()`);
expect(immediate).toEqual({ before: 12, ready: 0, reset: 0 });
const after = await waitForPlayback(
obsidian,
(state) => state.playing && (state.media?.seconds ?? 0) > 0,
"resumed after reset",
);
expect(after.seconds).toBeGreaterThanOrEqual(12);
expect(after.seconds).toBeLessThan(15);
});
}

test("switching after a pending native seek saves and queues the ended outgoing episode", async () => {
const { obsidian, plugin, incoming } = await startOutgoing();
await obsidian.dev.evalJson("app.plugins.plugins.podnotes.api.currentTime = 59, true");
await plugin.waitForData<PodNotesData>(
(data) => data.playedEpisodes?.["Media reset::Outgoing"]?.finished === true,
WAIT_OPTS,
);
await waitForPlayback(obsidian, (state) => !state.playing && state.seconds >= 59, "ended");
const result = await obsidian.dev.evalJson<{
before: number;
seek: number;
title: string;
}>(`(() => {
const plugin = app.plugins.plugins.podnotes;
const media = document.querySelector(".podnotes-media");
const before = media.currentTime;
media.currentTime = 12;
const seek = media.currentTime;
plugin.player.play(${jsLiteral(incoming)});
return { before, seek, title: plugin.api.podcast.title };
})()`);
expect(result).toEqual({ before: 60, seek: 12, title: "Incoming" });
await waitForPlayback(obsidian, (state) => state.playing, "incoming playing");
await obsidian.dev.evalJsonAsync(
"(async () => { const p = app.plugins.plugins.podnotes; p.player.pause(); await p.flushSaves(); return true; })()",
);
const data = await plugin.data<PodNotesData>().read();
expect(data.playedEpisodes?.["Media reset::Outgoing"]).toMatchObject({
time: 12,
duration: 60,
finished: false,
});
expect(data.queue?.episodes.map((episode) => episode.title)).toContain("Outgoing");
});
36 changes: 36 additions & 0 deletions tests/e2e/settings-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,42 @@ describe("PodNotes settings tab", () => {
).toEqual(["Skip back (s)", saved]);
});

test("blur keeps an external setting received after the focused edit was saved", async () => {
const { obsidian, plugin } = getContext();
const local = "Local/{{title}}.md";
const remote = "Remote/{{title}}.md";
await openPodNotesSettings(obsidian);
onTestFinished(() => closeSettings(obsidian));
const path = ui.settingControl("Episode note path", "input");
await typeInto(obsidian, path, local, { replace: true });
await plugin.waitForData<PodNotesData>((data) => data.note?.path === local, {
timeoutMs: 5_000,
intervalMs: 100,
});
const synced = await plugin.data<PodNotesData>().read();
if (!synced.note) throw new Error("The saved episode note settings are missing");
synced.note.path = remote;
await plugin.data<PodNotesData>().write(synced);
await obsidian.dev.evalJsonAsync(
"app.plugins.plugins.podnotes.onExternalSettingsChange().then(() => true)",
);
expect(
await obsidian.dev.evalJson(`({
memory: app.plugins.plugins.podnotes.settings.note.path,
field: document.activeElement.value,
focused: (${path.elements}).includes(document.activeElement)
})`),
).toEqual({ memory: remote, field: local, focused: true });

await pressKey(obsidian, "Tab");
await obsidian.dev.evalJsonAsync(
"app.plugins.plugins.podnotes.flushSaves().then(() => true)",
);

expect(await settingValue(obsidian, "Episode note path")).toBe(remote);
expect((await plugin.data<PodNotesData>().read()).note?.path).toBe(remote);
});

test("leaving a field saves a return to its original text before the debounce", async () => {
const { obsidian, plugin } = getContext();
const initial = "Initial/{{title}}.md";
Expand Down
Loading
Loading