diff --git a/src/walkthrough/index.ts b/src/walkthrough/index.ts index 1b35459..cc012b2 100644 --- a/src/walkthrough/index.ts +++ b/src/walkthrough/index.ts @@ -3,12 +3,14 @@ import type { Config } from "../plugins/config/index.ts"; import type { Logger } from "../plugins/logger/index.ts"; import type { SourceAdapter } from "../sources/adapters/index.ts"; import { getAdapter } from "../sources/adapters/index.ts"; +import type { SourceDescriptor } from "../sources/types.ts"; import { createProgress } from "./progress.ts"; import { checkAuth, refreshSession } from "./steps/auth-check.ts"; import { promptCoverUrl } from "./steps/cover-prompt.ts"; import type { ExecuteDeps } from "./steps/execute.ts"; import { executeWalkthrough } from "./steps/execute.ts"; import { pickMode } from "./steps/mode-picker.ts"; +import { promptNextAction } from "./steps/next-action-prompt.ts"; import { promptPack } from "./steps/pack-prompt.ts"; import { pickRange } from "./steps/range-picker.ts"; import { pickSearchResult } from "./steps/search-results-picker.ts"; @@ -16,7 +18,10 @@ import { pickSource } from "./steps/source-picker.ts"; import { promptTitle } from "./steps/title-prompt.ts"; import { promptVolumeName } from "./steps/volume-name-prompt.ts"; import type { + ChapterListing, + SearchHit, SessionProbeClientFactory, + VolumeListing, WalkthroughCancelled, WalkthroughInput, WalkthroughResult, @@ -67,6 +72,15 @@ export interface WalkthroughFailed { reason: string; } +/** + * In-memory cache of listings already fetched for the current manga (hit), + * so the "same manga" post-download branch never re-hits the adapter. + */ +interface ChapterListingCache { + chapters: ChapterListing[] | null; + volumes: VolumeListing[] | null; +} + /** * Composes all 9 walkthrough steps in order. * Returns the assembled plan + execution result on success. @@ -80,10 +94,7 @@ export async function runWalkthrough( const outDir = opts.outDir ?? process.cwd(); try { - // Step 1 — title - const title = await promptTitle(); - - // Step 2 — source + // Step 2 — source (picked once per session; reused across "new manga" iterations) const source = await pickSource(); // Step 3 — auth check (probe session when factory provided; production uses real fallback-http) @@ -122,80 +133,79 @@ export async function runWalkthrough( // Resolve adapter for this source (after auth check so session is persisted if needed) const adapter = resolveAdapter(source.id, { logger: opts.logger, config: opts.config }); - // Step 4 — search results (with CF retry) - const hit = await withSessionRetry( - () => - pickSearchResult({ - query: title, - sourceLabel: source.label, - adapter, - }), - isCloudflareError, - doRefresh, - opts.logger, - "walkthrough.search_retry", - ); - - // Step 5 — mode - const mode = await pickMode(); + let lastResult: WalkthroughResult | null = null; + let hit: SearchHit | null = null; + let cache: ChapterListingCache | null = null; - // Step 6 — range (with CF retry) - const selectedBundles = await withSessionRetry( - () => pickRange({ hit, mode, adapter }), - isCloudflareError, - doRefresh, - opts.logger, - "walkthrough.range_retry", - ); - - // Step 7 — pack prompt (chapter mode only) - // volume mode always packs - const groupIntoVolume = mode === "volume" ? true : await promptPack(); + // Post-auth/source loop: each iteration resolves a manga (search or reuse) and downloads. + outer: for (;;) { + // "New manga" entry point (also the first iteration): title + search. + if (hit === null) { + // Step 1 — title + const title = await promptTitle(); - // Step 7b — volume name (chapter mode + groupIntoVolume only; volume mode uses bundle.num) - const volumeName = - mode === "chapter" && groupIntoVolume - ? await promptVolumeName({ logger: opts.logger }) - : null; + // Step 4 — search results (with CF retry) + hit = await withSessionRetry( + () => + pickSearchResult({ + query: title, + sourceLabel: source.label, + adapter, + }), + isCloudflareError, + doRefresh, + opts.logger, + "walkthrough.search_retry", + ); + cache = { chapters: null, volumes: null }; - // Step 8 — cover URL (only when packing) - const coverUrl = groupIntoVolume ? await promptCoverUrl({ logger: opts.logger }) : null; - - const result: WalkthroughResult = { - title, - source, - hit, - mode, - selectedBundles, - groupIntoVolume, - volumeName, - coverUrl, - }; + lastResult = await runDownloadFlow({ + title, + hit, + cache, + adapter, + source, + outDir, + logger: opts.logger, + doRefresh, + executeDeps: opts.executeDeps, + progressEnabled: opts.progressEnabled ?? false, + }); + } else { + // "Same manga" entry point: reuse hit + cached listings, re-enter at pickMode. + if (cache === null) cache = { chapters: null, volumes: null }; + lastResult = await runDownloadFlow({ + title: lastResult?.title ?? "", + hit, + cache, + adapter, + source, + outDir, + logger: opts.logger, + doRefresh, + executeDeps: opts.executeDeps, + progressEnabled: opts.progressEnabled ?? false, + }); + } - // Step 9 — execute (fetchChapterInput calls are wrapped inside executeWalkthrough) - const progress = createProgress({ - enabled: opts.progressEnabled ?? false, - totalChapters: selectedBundles.length, - }); - await executeWalkthrough( - { - source, - hit, - mode, - selectedBundles, - groupIntoVolume, - volumeName, - coverUrl, - outDir, - adapter, - logger: opts.logger, - refreshFn: doRefresh, - progress, - }, - opts.executeDeps, - ); + // Post-download: what next? + const next = await promptNextAction(); + switch (next) { + case "same-manga": + continue outer; + case "new-manga": + hit = null; + cache = null; + continue outer; + case "quit": + break outer; + } + } - return result; + if (!lastResult) { + throw new WalkthroughError("Walkthrough ended without a completed download."); + } + return lastResult; } catch (err) { // @inquirer/prompts throws ExitPromptError when the user presses Ctrl+C if (err instanceof Error && err.name === "ExitPromptError") { @@ -211,3 +221,123 @@ export async function runWalkthrough( throw err; } } + +interface DownloadFlowOptions { + title: string; + hit: SearchHit; + cache: ChapterListingCache; + adapter: SourceAdapter; + source: SourceDescriptor; + outDir: string; + logger: Logger; + doRefresh: () => Promise; + executeDeps?: ExecuteDeps; + progressEnabled: boolean; +} + +/** + * Runs steps 5-9 (mode → range → pack prompts → execute) for an already-resolved + * manga (hit). Reuses cached chapter/volume listings when present so "same manga" + * iterations never re-call adapter.search or adapter.listChapters/listVolumes. + */ +async function runDownloadFlow(flowOpts: DownloadFlowOptions): Promise { + const { + title, + hit, + cache, + adapter, + source, + outDir, + logger, + doRefresh, + executeDeps, + progressEnabled, + } = flowOpts; + + // Step 5 — mode + const mode = await pickMode(); + + // Step 6 — range (with CF retry). Reuse cached listing for this hit when available. + const rangeResult = await withSessionRetry( + () => + pickRange({ + hit, + mode, + adapter, + preloadedChapters: mode === "chapter" ? (cache.chapters ?? undefined) : undefined, + preloadedVolumes: mode === "volume" ? (cache.volumes ?? undefined) : undefined, + }), + isCloudflareError, + doRefresh, + logger, + "walkthrough.range_retry", + ); + const selectedBundles = rangeResult.bundles; + + // Cache the listing actually used (fetched or preloaded) for subsequent "same manga" iterations. + if (rangeResult.chapters) cache.chapters = rangeResult.chapters; + if (rangeResult.volumes) cache.volumes = rangeResult.volumes; + + // Step 7 — pack prompt (chapter mode only) + // volume mode always packs + const groupIntoVolume = mode === "volume" ? true : await promptPack(); + + // Step 7b — volume name (chapter mode + groupIntoVolume only; volume mode uses bundle.num) + const volumeName = + mode === "chapter" && groupIntoVolume ? await promptVolumeName({ logger }) : null; + + // Step 8 — cover URL (only when packing) + const coverUrl = groupIntoVolume ? await promptCoverUrl({ logger }) : null; + + const result: WalkthroughResult = { + title, + source, + hit, + mode, + selectedBundles, + groupIntoVolume, + volumeName, + coverUrl, + }; + + // Step 9 — execute (fetchChapterInput calls are wrapped inside executeWalkthrough). + // A fresh progress handle is created per download flow iteration so the bar always + // reflects this iteration's own bundle count, then finished before returning. + const progress = createProgress({ + enabled: progressEnabled, + totalChapters: selectedBundles.length, + }); + const { failed } = await executeWalkthrough( + { + source, + hit, + mode, + selectedBundles, + groupIntoVolume, + volumeName, + coverUrl, + outDir, + adapter, + logger, + refreshFn: doRefresh, + progress, + }, + executeDeps, + ); + + // Partial failure is resilient: still return to the next-action prompt, but + // surface a one-line summary so the user isn't left guessing. + if (failed > 0) { + logger.warn( + { + event: "walkthrough.download_summary_failed", + context: "walkthrough", + failed, + total: selectedBundles.length, + }, + `${failed} chapter(s) failed to download`, + ); + } + + return result; +} diff --git a/src/walkthrough/steps/next-action-prompt.test.ts b/src/walkthrough/steps/next-action-prompt.test.ts new file mode 100644 index 0000000..cfed4c2 --- /dev/null +++ b/src/walkthrough/steps/next-action-prompt.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; + +describe("promptNextAction", () => { + test("returns 'same-manga' when selected", async () => { + const { mock } = await import("bun:test"); + mock.module("../prompts.ts", () => ({ + select: async () => "same-manga", + input: async () => "", + checkbox: async () => [], + confirm: async () => false, + editor: async () => "", + })); + const { promptNextAction } = await import("./next-action-prompt.ts"); + expect(await promptNextAction()).toBe("same-manga"); + }); + + test("returns 'new-manga' when selected", async () => { + const { mock } = await import("bun:test"); + mock.module("../prompts.ts", () => ({ + select: async () => "new-manga", + input: async () => "", + checkbox: async () => [], + confirm: async () => false, + editor: async () => "", + })); + const { promptNextAction } = await import("./next-action-prompt.ts"); + expect(await promptNextAction()).toBe("new-manga"); + }); + + test("returns 'quit' when selected", async () => { + const { mock } = await import("bun:test"); + mock.module("../prompts.ts", () => ({ + select: async () => "quit", + input: async () => "", + checkbox: async () => [], + confirm: async () => false, + editor: async () => "", + })); + const { promptNextAction } = await import("./next-action-prompt.ts"); + expect(await promptNextAction()).toBe("quit"); + }); +}); diff --git a/src/walkthrough/steps/next-action-prompt.ts b/src/walkthrough/steps/next-action-prompt.ts new file mode 100644 index 0000000..3c872e7 --- /dev/null +++ b/src/walkthrough/steps/next-action-prompt.ts @@ -0,0 +1,16 @@ +import { select } from "../prompts.ts"; + +export type NextAction = "same-manga" | "new-manga" | "quit"; + +/** Post-download step: offer to keep going with the same manga, search a new one, or quit. */ +export async function promptNextAction(): Promise { + const result = await select({ + message: "Done. What next?", + choices: [ + { name: "Same manga — pick another volume/range", value: "same-manga" }, + { name: "New manga — search again", value: "new-manga" }, + { name: "Quit", value: "quit" }, + ], + }); + return result; +} diff --git a/src/walkthrough/steps/range-picker.test.ts b/src/walkthrough/steps/range-picker.test.ts index 66db56b..eb970cf 100644 --- a/src/walkthrough/steps/range-picker.test.ts +++ b/src/walkthrough/steps/range-picker.test.ts @@ -57,9 +57,9 @@ describe("pickRange", () => { const adapter = makeFakeAdapter(mockChapters, mockVolumes); const { pickRange } = await import("./range-picker.ts"); const result = await pickRange({ hit: mockHit, mode: "chapter", adapter }); - expect(result).toHaveLength(2); - expect(result.map((b) => b.id)).toContain("mock-1-ch-1"); - expect(result.map((b) => b.id)).toContain("mock-1-ch-3"); + expect(result.bundles).toHaveLength(2); + expect(result.bundles.map((b) => b.id)).toContain("mock-1-ch-1"); + expect(result.bundles.map((b) => b.id)).toContain("mock-1-ch-3"); }); test("empty selection — throws error", async () => { @@ -94,7 +94,7 @@ describe("pickRange", () => { const adapter = makeFakeAdapter(mockChapters, mockVolumes); const { pickRange } = await import("./range-picker.ts"); const result = await pickRange({ hit: mockHit, mode: "volume", adapter }); - expect(result[0]?.label).toMatch(/Volume/); + expect(result.bundles[0]?.label).toMatch(/Volume/); }); test("chapter mode: throws WalkthroughError when adapter returns empty chapter list", async () => { diff --git a/src/walkthrough/steps/range-picker.ts b/src/walkthrough/steps/range-picker.ts index 2022319..79e6f14 100644 --- a/src/walkthrough/steps/range-picker.ts +++ b/src/walkthrough/steps/range-picker.ts @@ -1,20 +1,39 @@ import type { SourceAdapter } from "../../sources/adapters/index.ts"; import { checkbox } from "../prompts.ts"; -import type { BundleItem, ModeSelection, SearchHit } from "../types.ts"; +import type { + BundleItem, + ChapterListing, + ModeSelection, + SearchHit, + VolumeListing, +} from "../types.ts"; import { WalkthroughError } from "../types.ts"; export interface RangePickerOptions { hit: SearchHit; mode: ModeSelection; adapter: SourceAdapter; + /** + * Preloaded listings for the "same manga" fast path — when provided, the + * matching listing is reused instead of calling adapter.listChapters/listVolumes again. + */ + preloadedChapters?: ChapterListing[]; + preloadedVolumes?: VolumeListing[]; +} + +export interface RangePickerResult { + bundles: BundleItem[]; + /** The raw listing actually used (fetched or preloaded) — cache this for later reuse. */ + chapters?: ChapterListing[]; + volumes?: VolumeListing[]; } /** Step 6: multi-select available chapters or volumes. */ -export async function pickRange(opts: RangePickerOptions): Promise { - const { hit, mode, adapter } = opts; +export async function pickRange(opts: RangePickerOptions): Promise { + const { hit, mode, adapter, preloadedChapters, preloadedVolumes } = opts; if (mode === "volume") { - const volumes = await adapter.listVolumes(hit.id); + const volumes = preloadedVolumes ?? (await adapter.listVolumes(hit.id)); if (volumes.length === 0) { throw new WalkthroughError( "This source did not expose volume metadata for this title. Try chapter mode.", @@ -33,18 +52,21 @@ export async function pickRange(opts: RangePickerOptions): Promise const selected = volumes.filter((v) => selectedIds.includes(`vol:${v.volume}`)); if (selected.length === 0) throw new WalkthroughError("No bundles selected"); - return selected.map((v) => ({ - kind: "volume" as const, - label: v.label, - id: `vol:${v.volume}`, - num: v.volume, - chapterIds: v.chapterIds, - chapterNums: v.chapterNums, - })); + return { + bundles: selected.map((v) => ({ + kind: "volume" as const, + label: v.label, + id: `vol:${v.volume}`, + num: v.volume, + chapterIds: v.chapterIds, + chapterNums: v.chapterNums, + })), + volumes, + }; } // chapter mode - const chapters = await adapter.listChapters(hit.id); + const chapters = preloadedChapters ?? (await adapter.listChapters(hit.id)); if (chapters.length === 0) { throw new WalkthroughError( "No chapters found for this title. The source may not have any available chapters.", @@ -63,10 +85,13 @@ export async function pickRange(opts: RangePickerOptions): Promise const selected = chapters.filter((ch) => selectedIds.includes(ch.id)); if (selected.length === 0) throw new WalkthroughError("No bundles selected"); - return selected.map((ch) => ({ - kind: "chapter" as const, - label: ch.label, - id: ch.id, - num: ch.num, - })); + return { + bundles: selected.map((ch) => ({ + kind: "chapter" as const, + label: ch.label, + id: ch.id, + num: ch.num, + })), + chapters, + }; } diff --git a/src/walkthrough/walkthrough.test.ts b/src/walkthrough/walkthrough.test.ts index 848bb22..8b0ad23 100644 --- a/src/walkthrough/walkthrough.test.ts +++ b/src/walkthrough/walkthrough.test.ts @@ -84,7 +84,8 @@ describe("runWalkthrough — full happy path", () => { selectCall++; if (selectCall === 1) return "mangadex"; // source if (selectCall === 2) return "mock-1"; // search result - return "chapter"; // mode + if (selectCall === 3) return "chapter"; // mode + return "quit"; // next-action }, checkbox: async () => ["mock-1-ch-1", "mock-1-ch-2"], confirm: async () => true, @@ -134,7 +135,8 @@ describe("runWalkthrough — full happy path", () => { selectCall++; if (selectCall === 1) return "mangadex"; if (selectCall === 2) return "mock-1"; - return "chapter"; + if (selectCall === 3) return "chapter"; + return "quit"; // next-action }, checkbox: async () => ["mock-1-ch-1"], confirm: async () => true, @@ -171,7 +173,8 @@ describe("runWalkthrough — full happy path", () => { selectCall++; if (selectCall === 1) return "mangadex"; // source if (selectCall === 2) return "mock-1"; // search result - return "volume"; // mode + if (selectCall === 3) return "volume"; // mode + return "quit"; // next-action }, checkbox: async () => ["vol:1"], confirm: async () => false, @@ -210,7 +213,8 @@ describe("runWalkthrough — full happy path", () => { selectCall++; if (selectCall === 1) return "mangadex"; if (selectCall === 2) return "mock-1"; - return "chapter"; + if (selectCall === 3) return "chapter"; + return "quit"; // next-action }, checkbox: async () => ["mock-1-ch-1"], confirm: async () => false, @@ -244,7 +248,9 @@ describe("runWalkthrough — full happy path", () => { input: async () => { throw new ExitPromptError("User force closed the prompt"); }, - select: async () => "", + select: async () => { + throw new ExitPromptError("User force closed the prompt"); + }, checkbox: async () => [], confirm: async () => false, editor: async () => "", @@ -302,7 +308,8 @@ describe("runWalkthrough — full happy path", () => { selectCall++; if (selectCall === 1) return "mangadex"; // source if (selectCall === 2) return "mock-1"; // search result - return "chapter"; // mode + if (selectCall === 3) return "chapter"; // mode + return "quit"; // next-action }, checkbox: async () => ["mock-1-ch-1"], confirm: async () => false, @@ -386,7 +393,8 @@ describe("runWalkthrough — full happy path", () => { selectCall++; if (selectCall === 1) return "mangadex"; if (selectCall === 2) return "mock-1"; - return "chapter"; + if (selectCall === 3) return "chapter"; + return "quit"; // next-action }, checkbox: async () => ["mock-1-ch-1"], confirm: async () => false, @@ -432,7 +440,8 @@ describe("runWalkthrough — full happy path", () => { selectCall++; if (selectCall === 1) return "mangadex"; if (selectCall === 2) return "mock-1"; - return "chapter"; + if (selectCall === 3) return "chapter"; + return "quit"; // next-action }, checkbox: async () => ["mock-1-ch-1"], confirm: async () => false, @@ -551,3 +560,437 @@ describe("runWalkthrough — config threading to adapter factory", () => { expect(readCapturedCall()?.config?.preferred_languages).toEqual(["pt-br"]); }); }); + +describe("runWalkthrough — post-download loop", () => { + test("'Same manga' reuses the cached chapter list: adapter.search/listChapters called once each", async () => { + let inputCall = 0; + let selectCall = 0; + mock.module("./prompts.ts", () => ({ + input: async () => { + inputCall++; + return "Naruto"; // title (only asked once, "new manga" path not taken) + }, + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; // source + if (selectCall === 2) return "mock-1"; // search result + if (selectCall === 3) return "chapter"; // mode (iteration 1) + if (selectCall === 4) return "same-manga"; // next-action after iteration 1 + if (selectCall === 5) return "chapter"; // mode (iteration 2, same manga) + return "quit"; // next-action after iteration 2 + }, + checkbox: async () => ["mock-1-ch-1"], + confirm: async () => false, + editor: async () => "", + })); + + let searchCallCount = 0; + let listChaptersCallCount = 0; + const fakeAdapter = makeFakeAdapter({ + search: async () => { + searchCallCount++; + return fakeHits; + }, + listChapters: async () => { + listChaptersCallCount++; + return fakeChapters; + }, + }); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: () => fakeAdapter, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + expect(searchCallCount).toBe(1); + expect(listChaptersCallCount).toBe(1); + expect(inputCall).toBe(1); + // downloader called once per iteration (1 chapter selected each time) + expect((fakeDownloader.downloadBundle as ReturnType).mock.calls.length).toBe(2); + }); + + test("'New manga' returns to title/search without re-picking source or redoing auth", async () => { + let inputCall = 0; + let selectCall = 0; + let sourceSelectCount = 0; + mock.module("./prompts.ts", () => ({ + input: async () => { + inputCall++; + return inputCall === 1 ? "Naruto" : "Bleach"; + }, + select: async () => { + selectCall++; + if (selectCall === 1) { + sourceSelectCount++; + return "mangadex"; // source (picked once) + } + if (selectCall === 2) return "mock-1"; // search result (iteration 1) + if (selectCall === 3) return "chapter"; // mode (iteration 1) + if (selectCall === 4) return "new-manga"; // next-action after iteration 1 + if (selectCall === 5) return "mock-1"; // search result (iteration 2, new manga) + if (selectCall === 6) return "chapter"; // mode (iteration 2) + return "quit"; // next-action after iteration 2 + }, + checkbox: async () => ["mock-1-ch-1"], + confirm: async () => false, + editor: async () => "", + })); + + let searchCallCount = 0; + let authCallCount = 0; + let listChaptersCallCount = 0; + let listVolumesCallCount = 0; + const fakeAdapter = makeFakeAdapter({ + search: async () => { + searchCallCount++; + return fakeHits; + }, + listChapters: async () => { + listChaptersCallCount++; + return fakeChapters; + }, + listVolumes: async () => { + listVolumesCallCount++; + return fakeVolumes; + }, + }); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: (sourceId, factoryOpts) => { + authCallCount++; + expect(sourceId).toBe("mangadex"); + expect(factoryOpts).toBeDefined(); + return fakeAdapter; + }, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + expect(sourceSelectCount).toBe(1); // source only picked once + expect(authCallCount).toBe(1); // adapter (post-auth) resolved once, reused + expect(searchCallCount).toBe(2); // new manga re-searches + expect(inputCall).toBe(2); // title re-prompted for the new manga + // "new manga" resets the listing cache: iteration 2 must fetch its own chapter + // listing fresh, not reuse iteration 1's cached listing. + expect(listChaptersCallCount).toBe(2); + expect(listVolumesCallCount).toBe(0); + }); + + test("'Quit' after a download exits cleanly with the completed result", async () => { + let selectCall = 0; + mock.module("./prompts.ts", () => ({ + input: async () => "Naruto", + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; + if (selectCall === 2) return "mock-1"; + if (selectCall === 3) return "chapter"; + return "quit"; + }, + checkbox: async () => ["mock-1-ch-1"], + confirm: async () => false, + editor: async () => "", + })); + + const fakeAdapter = makeFakeAdapter(); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: () => fakeAdapter, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + expect(result.mode).toBe("chapter"); + }); + + test("Ctrl+C at the post-download 'what next?' prompt returns { cancelled: true }", async () => { + const ExitPromptError = class extends Error { + override name = "ExitPromptError"; + }; + let selectCall = 0; + mock.module("./prompts.ts", () => ({ + input: async () => "Naruto", + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; + if (selectCall === 2) return "mock-1"; + if (selectCall === 3) return "chapter"; + throw new ExitPromptError("User force closed the prompt"); + }, + checkbox: async () => ["mock-1-ch-1"], + confirm: async () => false, + editor: async () => "", + })); + + const fakeAdapter = makeFakeAdapter(); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: () => fakeAdapter, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + expect(result).toEqual({ cancelled: true }); + }); + + test("'Same manga' allows switching from chapter mode to volume mode across iterations", async () => { + let selectCall = 0; + mock.module("./prompts.ts", () => ({ + input: async () => "Naruto", + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; + if (selectCall === 2) return "mock-1"; + if (selectCall === 3) return "chapter"; // iteration 1: chapter mode + if (selectCall === 4) return "same-manga"; + if (selectCall === 5) return "volume"; // iteration 2: volume mode + return "quit"; + }, + checkbox: async (opts: { message: string }) => { + if (opts.message.includes("volumes")) return ["vol:1"]; + return ["mock-1-ch-1"]; + }, + confirm: async () => false, + editor: async () => "", + })); + + let listVolumesCallCount = 0; + const fakeAdapter = makeFakeAdapter({ + listVolumes: async () => { + listVolumesCallCount++; + return fakeVolumes; + }, + }); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: () => fakeAdapter, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + expect(result.mode).toBe("volume"); + expect(listVolumesCallCount).toBe(1); + }); + + test("'Same manga' allows switching from volume mode to chapter mode across iterations", async () => { + let selectCall = 0; + mock.module("./prompts.ts", () => ({ + input: async () => "Naruto", + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; + if (selectCall === 2) return "mock-1"; + if (selectCall === 3) return "volume"; // iteration 1: volume mode + if (selectCall === 4) return "same-manga"; + if (selectCall === 5) return "chapter"; // iteration 2: chapter mode + return "quit"; + }, + checkbox: async (opts: { message: string }) => { + if (opts.message.includes("volumes")) return ["vol:1"]; + return ["mock-1-ch-1"]; + }, + confirm: async () => false, + editor: async () => "", + })); + + let listChaptersCallCount = 0; + let listVolumesCallCount = 0; + const fakeAdapter = makeFakeAdapter({ + listChapters: async () => { + listChaptersCallCount++; + return fakeChapters; + }, + listVolumes: async () => { + listVolumesCallCount++; + return fakeVolumes; + }, + }); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: () => fakeAdapter, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + expect(result.mode).toBe("chapter"); + expect(listVolumesCallCount).toBe(1); + expect(listChaptersCallCount).toBe(1); + }); + + test("3 iterations chapter->volume->chapter (same manga): no stale cache leak, each listing fetched once", async () => { + let selectCall = 0; + mock.module("./prompts.ts", () => ({ + input: async () => "Naruto", + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; + if (selectCall === 2) return "mock-1"; + if (selectCall === 3) return "chapter"; // iteration 1: chapter mode + if (selectCall === 4) return "same-manga"; + if (selectCall === 5) return "volume"; // iteration 2: volume mode + if (selectCall === 6) return "same-manga"; + if (selectCall === 7) return "chapter"; // iteration 3: chapter mode (reuse iter-1 cache) + return "quit"; + }, + checkbox: async (opts: { message: string }) => { + if (opts.message.includes("volumes")) return ["vol:1"]; + return ["mock-1-ch-1"]; + }, + confirm: async () => false, + editor: async () => "", + })); + + let listChaptersCallCount = 0; + let listVolumesCallCount = 0; + const fakeAdapter = makeFakeAdapter({ + listChapters: async () => { + listChaptersCallCount++; + return fakeChapters; + }, + listVolumes: async () => { + listVolumesCallCount++; + return fakeVolumes; + }, + }); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: () => fakeAdapter, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + expect(result.mode).toBe("chapter"); + // chapter listing fetched once (iter 1) and reused in iter 3, not re-fetched + expect(listChaptersCallCount).toBe(1); + // volume listing fetched once (iter 2) + expect(listVolumesCallCount).toBe(1); + // downloader called once per iteration + expect((fakeDownloader.downloadBundle as ReturnType).mock.calls.length).toBe(3); + }); + + test("partial download failure: loop still reaches next-action prompt and logs a failure summary", async () => { + let selectCall = 0; + const warnMessages: string[] = []; + const warnLogger = createLogger({ + level: "info", + format: "human", + write: (line: string) => { + warnMessages.push(line); + }, + }); + mock.module("./prompts.ts", () => ({ + input: async () => "Naruto", + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; + if (selectCall === 2) return "mock-1"; + if (selectCall === 3) return "chapter"; // iteration 1 + if (selectCall === 4) return "same-manga"; // proves loop still offers next-action + if (selectCall === 5) return "chapter"; // iteration 2 + return "quit"; + }, + checkbox: async () => ["mock-1-ch-1", "mock-1-ch-2"], + confirm: async () => false, + editor: async () => "", + })); + + let fetchCallCount = 0; + const fakeAdapter = makeFakeAdapter({ + fetchChapterInput: async (id: string, num?: string) => { + fetchCallCount++; + if (fetchCallCount === 1) throw new Error("upstream 500"); + return { ...fakeChapterInput, id, num: num ? Number(num) : fakeChapterInput.num }; + }, + }); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger: warnLogger, + outDir, + adapterFactory: () => fakeAdapter, + probeClientFactory: null, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + // loop resumed after partial failure ("same manga") and completed a second iteration + expect(result.mode).toBe("chapter"); + expect(warnMessages.some((line) => /chapter\(s\) failed to download/.test(line))).toBe(true); + }); + + test("partial download failure then 'quit' returns the completed result", async () => { + let selectCall = 0; + mock.module("./prompts.ts", () => ({ + input: async () => "Naruto", + select: async () => { + selectCall++; + if (selectCall === 1) return "mangadex"; + if (selectCall === 2) return "mock-1"; + if (selectCall === 3) return "chapter"; + return "quit"; + }, + checkbox: async () => ["mock-1-ch-1", "mock-1-ch-2"], + confirm: async () => false, + editor: async () => "", + })); + + let fetchCallCount = 0; + const fakeAdapter = makeFakeAdapter({ + fetchChapterInput: async (id: string, num?: string) => { + fetchCallCount++; + if (fetchCallCount === 1) throw new Error("upstream 500"); + return { ...fakeChapterInput, id, num: num ? Number(num) : fakeChapterInput.num }; + }, + }); + const fakeDownloader = makeFakeDownloader(); + const fakePacker = makeFakePacker(); + const { runWalkthrough } = await import("./index.ts"); + const result = await runWalkthrough({ + logger, + outDir, + adapterFactory: () => fakeAdapter, + probeClientFactory: null, + executeDeps: { downloader: fakeDownloader, packer: fakePacker }, + }); + + if ("cancelled" in result) throw new Error("Unexpected cancellation"); + if ("ok" in result) throw new Error(`Unexpected failure: ${result.reason}`); + expect(result.mode).toBe("chapter"); + }); +});