From e2c9dd3f4f16eb98981cda1e200a949b8cea122b Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:34:09 +0800 Subject: [PATCH 1/5] fix: retain accurate partial and cancelled export results --- src/export/ExportIntegrity.test.ts | 13 +- src/export/ExportOutcome.test.ts | 247 ++++++++++++++++++ src/export/ExportOutcome.ts | 25 ++ src/export/ExportRunner.test.ts | 70 +++--- src/export/ExportRunner.ts | 385 +++++++++++++++++------------ 5 files changed, 556 insertions(+), 184 deletions(-) create mode 100644 src/export/ExportOutcome.test.ts create mode 100644 src/export/ExportOutcome.ts diff --git a/src/export/ExportIntegrity.test.ts b/src/export/ExportIntegrity.test.ts index 02948b5..1fdc45a 100644 --- a/src/export/ExportIntegrity.test.ts +++ b/src/export/ExportIntegrity.test.ts @@ -182,8 +182,10 @@ describe("export integrity", () => { }, ); + expect(result.status).toBe("failed"); expect(result.success).toBe(false); - expect(result.warnings[0]).toContain("Output already exists: exports/A.md"); + expect(result.errors[0]).toContain("Export failed for a/A.md: Output already exists: exports/A.md"); + expect(result.incompletePaths).toContain("exports/A.md"); expect(fixture.text("exports/A.md")).toBe("PRE-EXISTING"); }); @@ -206,7 +208,7 @@ describe("export integrity", () => { expect(Array.from(fixture.bytes("exports/assets/img.png"))).toEqual([9]); }); - it("surfaces a missing attachment as a failed copy warning", async () => { + it("records a missing attachment as a failed copy error with an incomplete primary", async () => { const fixture = createMemoryVault(); fixture.putText("a/A.md", "![[a/img.png]]"); fixture.putBinary("a/img.png", new Uint8Array([1])); @@ -225,8 +227,11 @@ describe("export integrity", () => { }, ); - expect(result.success).toBe(true); - expect(result.warnings).toContain("Failed to copy attachment: a/img.png"); + expect(result.status).toBe("failed"); + expect(result.success).toBe(false); + expect(result.completedFiles).toBe(0); + expect(result.incompletePaths).toContain("exports/A.md"); + expect(result.errors).toContain("Failed to copy attachment: a/img.png"); expect(fixture.text("exports/export-report.md")).toContain("Failed to copy attachment"); }); }); diff --git a/src/export/ExportOutcome.test.ts b/src/export/ExportOutcome.test.ts new file mode 100644 index 0000000..d7c072b --- /dev/null +++ b/src/export/ExportOutcome.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveExportStatus } from "@/export/ExportOutcome"; +import { ExportRunner } from "@/export/ExportRunner"; +import { ExportPlanBuilder } from "@/export/ExportPlan"; +import { DEFAULT_SETTINGS, ExportSettings } from "@/types"; +import { createMemoryVault } from "@/test-support/memory-vault"; +import type { AssembledDocument } from "@/types"; + +const { renderPdfMock } = vi.hoisted(() => ({ + renderPdfMock: vi.fn<[AssembledDocument, ...unknown[]], Promise>(), +})); + +vi.mock("@/formats/pdf", () => ({ + renderPdf: renderPdfMock, +})); + +function outcomeSettings(overrides: Partial = {}): ExportSettings { + return { + ...DEFAULT_SETTINGS, + expandEmbeds: false, + copyAttachments: true, + overwriteExisting: false, + ...overrides, + }; +} + +function singleFilePlan( + fixture: ReturnType, + path: string, + name: string, +) { + return new ExportPlanBuilder( + fixture.app, { type: "current-file", path }, + "markdown-bundle", "exports", name, + ).setInputFiles([path]).build(); +} + +function filesPlan( + fixture: ReturnType, + paths: string[], +) { + return new ExportPlanBuilder( + fixture.app, { type: "files", paths }, + "markdown-bundle", "exports", "output", "files", + ).setInputFiles(paths).build(); +} + +const noCallbacks = { + onFileStart: () => {}, + onFileComplete: () => {}, + onPhase: () => {}, +}; + +afterEach(() => { + vi.restoreAllMocks(); + renderPdfMock.mockReset(); +}); + +describe("resolveExportStatus", () => { + it.each([ + [false, 2, 2, false, "completed"], + [false, 1, 2, true, "partial"], + [false, 0, 2, true, "failed"], + [true, 0, 2, false, "cancelled"], + [true, 1, 2, false, "cancelled"], + [true, 2, 2, false, "cancelled"], + [false, 0, 0, false, "failed"], + [false, 2, 2, true, "partial"], + ] as const)("resolves outcome %s/%i/%i/%s", (cancelled, done, total, failure, expected) => { + expect(resolveExportStatus(cancelled, done, total, failure)).toBe(expected); + }); +}); + +describe("export outcomes", () => { + it("cancel during the assembling phase leaves no primary output", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "content"); + const runner = new ExportRunner(fixture.app); + + const result = await runner.run( + singleFilePlan(fixture, "a/A.md", "A"), + outcomeSettings(), + { ...noCallbacks, onPhase: (phase) => phase === "Assembling document" && runner.cancel() }, + ); + + expect(result).toMatchObject({ status: "cancelled", success: false, completedFiles: 0 }); + expect(fixture.paths()).not.toContain("exports/A.md"); + }); + + it("cancel in the first onFileComplete of a three-file batch preserves the first output and diagnostics", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "Link to [[missing]]"); + fixture.putText("b/B.md", "content"); + fixture.putText("c/C.md", "content"); + const runner = new ExportRunner(fixture.app); + + const result = await runner.run( + filesPlan(fixture, ["a/A.md", "b/B.md", "c/C.md"]), + outcomeSettings(), + { + ...noCallbacks, + onFileComplete: () => runner.cancel(), + }, + ); + + expect(result.status).toBe("cancelled"); + expect(result.success).toBe(false); + expect(result.completedFiles).toBe(1); + expect(result.completedPaths).toEqual(["exports/files/a/A.md"]); + expect(result.warnings).toContain("Unresolved link: missing"); + expect(result.warnings.some((w) => w.includes("1 of 3 file(s) exported"))).toBe(true); + expect(fixture.text("exports/files/a/A.md")).toContain("missing"); + }); + + it("cancel in the copy phase after rendering lists the primary as incomplete", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "![[a/img.png]]"); + fixture.putBinary("a/img.png", new Uint8Array([1])); + const runner = new ExportRunner(fixture.app); + + const result = await runner.run( + singleFilePlan(fixture, "a/A.md", "A"), + outcomeSettings(), + { ...noCallbacks, onPhase: (phase) => phase === "Copying attachments" && runner.cancel() }, + ); + + expect(result).toMatchObject({ status: "cancelled", success: false, completedFiles: 0 }); + expect(result.incompletePaths).toContain("exports/A.md"); + expect(fixture.text("exports/A.md")).toContain("assets/"); + }); + + it("a read failure on the second source stays partial with prior warnings and the new error", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "Link to [[missing]]"); + // A binary file with an .md extension passes input filtering but fails + // assembly with a read error, without being a "missing input". + fixture.putBinary("b/B.md", new Uint8Array([0])); + + const result = await new ExportRunner(fixture.app) + .run(filesPlan(fixture, ["a/A.md", "b/B.md"]), outcomeSettings()); + + expect(result.status).toBe("partial"); + expect(result.success).toBe(false); + expect(result.completedPaths).toEqual(["exports/files/a/A.md"]); + expect(result.warnings).toContain("Unresolved link: missing"); + expect(result.errors[0]).toContain("Export failed for b/B.md"); + expect(fixture.text("exports/files/a/A.md")).toContain("missing"); + }); + + it("a rejected renderer fails the run and lists the potential primary", async () => { + renderPdfMock.mockRejectedValue(new Error("PDF generation failed: no window")); + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "content"); + + const plan = new ExportPlanBuilder( + fixture.app, { type: "current-file", path: "a/A.md" }, + "pdf", "exports", "A", + ).setInputFiles(["a/A.md"]).build(); + const result = await new ExportRunner(fixture.app).run(plan, outcomeSettings()); + + expect(result.status).toBe("failed"); + expect(result.success).toBe(false); + expect(result.completedFiles).toBe(0); + expect(result.incompletePaths).toContain("exports/A.pdf"); + expect(result.errors[0]).toContain("PDF generation failed"); + }); + + it("a shared attachment that fails once is retried for the later source", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "![[shared/img.png]]"); + fixture.putText("b/B.md", "![[shared/img.png]]"); + fixture.putBinary("shared/img.png", new Uint8Array([5])); + + const result = await new ExportRunner(fixture.app).run( + filesPlan(fixture, ["a/A.md", "b/B.md"]), + outcomeSettings(), + { + ...noCallbacks, + onPhase: (phase) => { + // The source exists for A's collection, disappears before + // A's copy, and returns before B collects and copies it. + if (phase === "Copying attachments for A") fixture.remove("shared/img.png"); + if (phase === "Collecting attachments for B") { + fixture.putBinary("shared/img.png", new Uint8Array([5])); + } + }, + }, + ); + + expect(result.status).toBe("partial"); + expect(result.completedPaths).toEqual(["exports/files/b/B.md"]); + expect(result.incompletePaths).toContain("exports/files/a/A.md"); + expect(result.errors).toContain("Failed to copy attachment: shared/img.png"); + expect(Array.from(fixture.bytes("exports/files/assets/img.png"))).toEqual([5]); + }); + + it("a missing requested input keeps the original total and is named in errors", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "content"); + + const result = await new ExportRunner(fixture.app) + .run(filesPlan(fixture, ["a/A.md", "b/B.md"]), outcomeSettings()); + + expect(result.status).toBe("partial"); + expect(result.totalFiles).toBe(2); + expect(result.completedFiles).toBe(1); + expect(result.errors).toContain("Input file not found or not a Markdown note: b/B.md"); + expect(fixture.text("exports/files/a/A.md")).toContain("A"); + }); + + it("a report write failure stays visible as a warning without losing documents", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "Link to [[missing]]"); + const vault = fixture.app.vault; + const create = vault.create.bind(vault) as unknown as + (path: string, content: string) => Promise; + vault.create = async (path: string, content: string) => { + if (path.endsWith("export-report.md")) { + throw new Error("simulated report failure"); + } + return create(path, content); + }; + + const result = await new ExportRunner(fixture.app) + .run(singleFilePlan(fixture, "a/A.md", "A"), outcomeSettings()); + + expect(result.status).toBe("completed"); + expect(result.success).toBe(true); + expect(result.reportPath).toBeUndefined(); + expect(result.warnings).toContain("Could not write export report: simulated report failure"); + expect(fixture.text("exports/A.md")).toContain("# A"); + }); + + it("an unresolved-link warning alone stays completed with a warning, not failed", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "Link to [[missing]]"); + + const result = await new ExportRunner(fixture.app) + .run(singleFilePlan(fixture, "a/A.md", "A"), outcomeSettings()); + + expect(result.status).toBe("completed"); + expect(result.success).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toContain("Unresolved link: missing"); + expect(result.reportPath).toBe("exports/export-report.md"); + }); +}); diff --git a/src/export/ExportOutcome.ts b/src/export/ExportOutcome.ts new file mode 100644 index 0000000..a09ee5d --- /dev/null +++ b/src/export/ExportOutcome.ts @@ -0,0 +1,25 @@ +export type ExportStatus = "completed" | "partial" | "cancelled" | "failed"; + +export interface ExportResult { + status: ExportStatus; + success: boolean; // true only for completed; UI switches on status + outputRoot: string; // actual batch leaf or relocated single-file root + totalFiles: number; // original requested input count + completedFiles: number; // fully processed primary files + completedPaths: string[]; + incompletePaths: string[]; // outputs that may exist but are not complete + warnings: string[]; + errors: string[]; + reportPath?: string; +} + +export function resolveExportStatus( + cancelled: boolean, + completed: number, + total: number, + hasFailure: boolean, +): ExportStatus { + if (cancelled) return "cancelled"; + if (completed === total && total > 0 && !hasFailure) return "completed"; + return completed > 0 ? "partial" : "failed"; +} diff --git a/src/export/ExportRunner.test.ts b/src/export/ExportRunner.test.ts index 4ef7187..55f3a26 100644 --- a/src/export/ExportRunner.test.ts +++ b/src/export/ExportRunner.test.ts @@ -41,29 +41,33 @@ function createMockApp(files: string[]) { }; } -function createPathAwareMockApp( - files: string[], - existingFolders: string[] = [], - existingFiles: string[] = [], -) { - const pathMap = new Map(); - for (const path of files) pathMap.set(path, createFile(path)); - for (const path of existingFolders) pathMap.set(path, { path, children: [] }); - for (const path of existingFiles) pathMap.set(path, createFile(path)); + function createPathAwareMockApp( + files: string[], + existingFolders: string[] = [], + existingFiles: string[] = [], + ) { + const pathMap = new Map(); + for (const path of files) pathMap.set(path, createFile(path)); + for (const path of existingFolders) pathMap.set(path, { path, children: [] }); + for (const path of existingFiles) pathMap.set(path, createFile(path)); - return { - vault: { - getAbstractFileByPath: vi.fn((path: string) => pathMap.get(path) ?? null), - read: vi.fn((_file?: { path: string }) => Promise.resolve("content")), - getMarkdownFiles: vi.fn(() => []), - createFolder: vi.fn().mockResolvedValue(undefined), - create: vi.fn().mockResolvedValue(undefined), - modify: vi.fn().mockResolvedValue(undefined), - createBinary: vi.fn().mockResolvedValue(undefined), - modifyBinary: vi.fn().mockResolvedValue(undefined), - readBinary: vi.fn(() => Promise.resolve(new ArrayBuffer(0))), - adapter: {}, - }, + return { + vault: { + getAbstractFileByPath: vi.fn((path: string) => pathMap.get(path) ?? null), + read: vi.fn((_file?: { path: string }) => Promise.resolve("content")), + getMarkdownFiles: vi.fn(() => []), + // Folder creation registers the folder so later existence checks + // (report writing) see what the run actually created. + createFolder: vi.fn(async (path: string) => { + pathMap.set(path, { path, children: [] }); + }), + create: vi.fn().mockResolvedValue(undefined), + modify: vi.fn().mockResolvedValue(undefined), + createBinary: vi.fn().mockResolvedValue(undefined), + modifyBinary: vi.fn().mockResolvedValue(undefined), + readBinary: vi.fn(() => Promise.resolve(new ArrayBuffer(0))), + adapter: {}, + }, metadataCache: { getFileCache: vi.fn(() => ({ frontmatter: {}, links: [], embeds: [] })), getFirstLinkpathDest: vi.fn( @@ -220,7 +224,9 @@ describe("ExportRunner", () => { ); expect(copySpy).toHaveBeenCalledTimes(3); - expect(result.success).toBe(true); + expect(result.status).toBe("cancelled"); + expect(result.success).toBe(false); + expect(result.completedFiles).toBe(1); expect(result.warnings[0]).toContain("1 of 2 file(s) exported"); }); @@ -242,12 +248,13 @@ describe("ExportRunner", () => { }; const result = await runner.run(plan, defaultSettings(), callbacks); - expect(result.success).toBe(true); + expect(result.status).toBe("cancelled"); + expect(result.success).toBe(false); expect(result.warnings[0]).toContain("cancelled"); expect(result.warnings[0]).toContain("file(s) exported"); }); - it("returns failure when cancelled before any file completes", async () => { + it("returns cancelled when cancelled before any file completes", async () => { const app = createMockApp(["a.md", "b.md"]); const plan = makePlan(["a.md", "b.md"]); const runner = new ExportRunner(app as never); @@ -261,7 +268,9 @@ describe("ExportRunner", () => { }; const result = await runner.run(plan, defaultSettings(), callbacks); + expect(result.status).toBe("cancelled"); expect(result.success).toBe(false); + expect(result.completedFiles).toBe(0); expect(result.warnings[0]).toContain("cancelled"); }); }); @@ -278,8 +287,10 @@ describe("ExportRunner", () => { onPhase: vi.fn(), }); + expect(result.status).toBe("failed"); expect(result.success).toBe(false); - expect(result.warnings[0]).toContain("PDF generation failed"); + expect(result.errors[0]).toContain("PDF generation failed"); + expect(result.incompletePaths.length).toBeGreaterThan(0); }); it("rejects PDF on mobile before creating output artifacts", async () => { @@ -290,8 +301,10 @@ describe("ExportRunner", () => { const result = await runner.run(makePdfPlan(["a.md"]), defaultSettings()); + expect(result.status).toBe("failed"); expect(result.success).toBe(false); - expect(result.warnings).toEqual(["PDF export requires the desktop app."]); + expect(result.warnings).toEqual([]); + expect(result.errors).toEqual(["PDF export requires the desktop app."]); expect(app.vault.createFolder).not.toHaveBeenCalled(); expect(app.vault.create).not.toHaveBeenCalled(); expect(app.vault.createBinary).not.toHaveBeenCalled(); @@ -397,7 +410,8 @@ describe("ExportRunner", () => { const noConflictApp = createPathAwareMockApp(["notes/a.md"], ["exports"]); const noConflictResult = await new ExportRunner(noConflictApp as never) .run(plan, defaultSettings()); - expect(noConflictResult.outputRoot).toBe("exports"); + expect(noConflictResult.outputRoot).toBe("exports/notes"); + expect(noConflictResult.completedPaths).toEqual(["exports/notes/a.md"]); expect(writeSpy).toHaveBeenCalledWith( "exports/notes/a.md", expect.any(String), diff --git a/src/export/ExportRunner.ts b/src/export/ExportRunner.ts index 5b893d7..1c97388 100644 --- a/src/export/ExportRunner.ts +++ b/src/export/ExportRunner.ts @@ -12,14 +12,11 @@ import { renderEpub } from "@/formats/epub"; import { relocatePlan } from "@/export/ExportPlan"; import { isProfileSupported } from "@/export/ProfileCapabilities"; import { joinMarkdownFragments } from "@/export/FragmentJoiner"; +import { ExportResult, resolveExportStatus } from "@/export/ExportOutcome"; -const WIKI_EMBED_RE = /!\[\[([^\]]+)]]/g; +export type { ExportResult } from "@/export/ExportOutcome"; -export interface ExportResult { - success: boolean; - outputRoot: string; - warnings: string[]; -} +const WIKI_EMBED_RE = /!\[\[([^\]]+)]]/g; export interface ExportProgressCallbacks { onFileStart: (fileIndex: number, totalFiles: number, fileName: string) => void; @@ -54,37 +51,44 @@ export class ExportRunner { ): Promise { const writer = new OutputWriter(this.app, settings.overwriteExisting); const allWarnings: string[] = []; + const errors: string[] = []; + const completedPaths: string[] = []; + const incompletePaths = new Set(); + let hasFailure = false; + let reportPath: string | undefined; + let enteredOutputStage = false; this.cancelled = false; + // Preflight failures produce no outputs and never create report folders. if (!isProfileSupported(plan.profile, Platform.isDesktopApp)) { - return { - success: false, - outputRoot: plan.outputRoot, - warnings: ["PDF export requires the desktop app."], - }; + return this.failedPreflight(plan, ["PDF export requires the desktop app."]); } if (!OutputWriter.supportsExternalPaths() && writer.isExternal(plan.outputRoot)) { - return { - success: false, - outputRoot: plan.outputRoot, - warnings: ["External paths are not supported on mobile. Use a vault-relative path."], - }; + return this.failedPreflight( + plan, + ["External paths are not supported on mobile. Use a vault-relative path."], + ); } - const files = plan.inputFiles + // Missing requested inputs stay counted in the total and are named as + // errors; the remaining valid files still export. + const requestedPaths = plan.inputFiles; + const files = requestedPaths .map((p) => this.app.vault.getAbstractFileByPath(p)) .filter( (f): f is import("obsidian").TFile => f !== null && "extension" in f && (f as import("obsidian").TFile).extension === "md", ); + const validPaths = files.map((f) => f.path); + for (const missing of requestedPaths.filter((p) => !validPaths.includes(p))) { + errors.push(`Input file not found or not a Markdown note: ${missing}`); + hasFailure = true; + } if (files.length === 0) { - return { - success: false, - outputRoot: plan.outputRoot, - warnings: ["No valid files found for export."], - }; + errors.push("No valid files found for export."); + return this.failedPreflight(plan, errors); } if (files.length > 500) { @@ -117,88 +121,94 @@ export class ExportRunner { : null; const isSingleFile = files.length === 1; - let completedFiles = 0; + runLoop: for (let i = 0; i < files.length; i++) { - if (this.cancelled) return this.cancelledResult(outputRoot, completedFiles, files.length); + if (this.cancelled) break; const file = files[i]; const outputFilePath = outputPathMap.get(file.path) ?? effectivePlan.outputFiles[i]; + let attachmentFailed = false; callbacks?.onFileStart(i, files.length, file.basename); - // Step 1: Assemble single-file document - callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[0] : `Assembling ${file.basename}`); - const doc = await assembler.assemble([file]); - allWarnings.push(...(doc.warnings ?? [])); - if (this.cancelled) return this.cancelledResult(outputRoot, completedFiles, files.length); - - // Step 2: Collect attachments for this file (embedded notes contribute - // their own references; AttachmentCollector only adds non-markdown files) - let attachments = effectivePlan.attachmentCopies; - if (collector) { - callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[1] : `Collecting attachments for ${file.basename}`); - const embeddedFiles = (doc.embeddedPaths ?? []) - .map((p) => this.app.vault.getAbstractFileByPath(p)) - .filter( - (f): f is import("obsidian").TFile => - f !== null && "extension" in f && (f as import("obsidian").TFile).extension === "md", - ); - const collectResult = await collector.collect([file, ...embeddedFiles]); - attachments = collectResult.attachments; - allWarnings.push(...collectResult.warnings); - } - doc.attachments = attachments; - if (this.cancelled) return this.cancelledResult(outputRoot, completedFiles, files.length); - - // Step 3: Rewrite links — per fragment, so content from embedded notes - // resolves against its own source path rather than the host's. - callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[2] : `Rewriting links in ${file.basename}`); - const rewriter = new LinkRewriter( - this.app, - exportedPaths, - attachments, - effectivePlan.profile, - outputPathMap, - outputFilePath, - assetsRoot, - ); - let sawUnexpandedEmbed = false; - for (const section of doc.sections) { - const fragments = section.fragments - ?? [{ markdown: section.markdown, sourcePath: section.sourcePath }]; - const rewritten: DocumentFragment[] = []; - for (const fragment of fragments) { - if ( - !settings.expandEmbeds - && this.containsUnexpandedNoteEmbed( - fragment.markdown, - fragment.sourcePath, - ) - ) { - sawUnexpandedEmbed = true; + try { + // Step 1: Assemble single-file document + callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[0] : `Assembling ${file.basename}`); + const doc = await assembler.assemble([file]); + allWarnings.push(...(doc.warnings ?? [])); + if (this.cancelled) break; + + // Step 2: Collect attachments for this file (embedded notes contribute + // their own references; AttachmentCollector only adds non-markdown files) + let attachments = effectivePlan.attachmentCopies; + if (collector) { + callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[1] : `Collecting attachments for ${file.basename}`); + const embeddedFiles = (doc.embeddedPaths ?? []) + .map((p) => this.app.vault.getAbstractFileByPath(p)) + .filter( + (f): f is import("obsidian").TFile => + f !== null && "extension" in f && (f as import("obsidian").TFile).extension === "md", + ); + const collectResult = await collector.collect([file, ...embeddedFiles]); + attachments = collectResult.attachments; + allWarnings.push(...collectResult.warnings); + } + doc.attachments = attachments; + if (this.cancelled) break; + + // Step 3: Rewrite links — per fragment, so content from embedded notes + // resolves against its own source path rather than the host's. + callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[2] : `Rewriting links in ${file.basename}`); + const rewriter = new LinkRewriter( + this.app, + exportedPaths, + attachments, + effectivePlan.profile, + outputPathMap, + outputFilePath, + assetsRoot, + ); + let sawUnexpandedEmbed = false; + for (const section of doc.sections) { + const fragments = section.fragments + ?? [{ markdown: section.markdown, sourcePath: section.sourcePath }]; + const rewritten: DocumentFragment[] = []; + for (const fragment of fragments) { + if ( + !settings.expandEmbeds + && this.containsUnexpandedNoteEmbed( + fragment.markdown, + fragment.sourcePath, + ) + ) { + sawUnexpandedEmbed = true; + } + const result = rewriter.rewrite(fragment.markdown, fragment.sourcePath); + rewritten.push({ ...fragment, markdown: result.markdown }); + allWarnings.push(...result.warnings); } - const result = rewriter.rewrite(fragment.markdown, fragment.sourcePath); - rewritten.push({ ...fragment, markdown: result.markdown }); - allWarnings.push(...result.warnings); + section.markdown = joinMarkdownFragments(rewritten); } - section.markdown = joinMarkdownFragments(rewritten); - } - // Without this hint, embeds silently degrading to plain text looks - // like a broken feature instead of a disabled one. - if (!settings.expandEmbeds && sawUnexpandedEmbed) { - allWarnings.push("Note embeds were not expanded (Expand note embeds setting is off)"); - } - if (this.cancelled) return this.cancelledResult(outputRoot, completedFiles, files.length); - - // Step 4: Ensure output folder exists - const outputDir = outputFilePath.substring(0, outputFilePath.lastIndexOf("/")); - await writer.ensureFolder(outputDir); - - // Step 5: Render format - callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[3] : `Rendering ${file.basename}`); - let formatWarnings: string[] = []; - try { + // Without this hint, embeds silently degrading to plain text looks + // like a broken feature instead of a disabled one. + if (!settings.expandEmbeds && sawUnexpandedEmbed) { + allWarnings.push("Note embeds were not expanded (Expand note embeds setting is off)"); + } + if (this.cancelled) break; + + // Step 4: Ensure output folder exists — from here on the run has + // entered its output-writing stage. + const outputDir = outputFilePath.substring(0, outputFilePath.lastIndexOf("/")); + await writer.ensureFolder(outputDir); + enteredOutputStage = true; + + // Step 5: Render format. The renderer can fail after partially + // writing, so the primary counts as incomplete until all of its + // required writes have succeeded. + callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[3] : `Rendering ${file.basename}`); + incompletePaths.add(outputFilePath); + let formatWarnings: string[] = []; switch (effectivePlan.profile) { case "markdown-bundle": formatWarnings = await renderMarkdownBundle(doc, effectivePlan, writer, outputFilePath); @@ -216,63 +226,88 @@ export class ExportRunner { formatWarnings = await renderEpub(doc, effectivePlan, writer, this.app, outputFilePath); break; } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - success: false, - outputRoot, - warnings: [msg], - }; - } - allWarnings.push(...formatWarnings); - if (this.cancelled) return this.cancelledResult(outputRoot, completedFiles, files.length); - - // Step 6: Copy attachments (deduplicate across files) — not for EPUB, - // whose images are packaged inside the .epub itself. - if ( - settings.copyAttachments - && effectivePlan.profile !== "epub" - && doc.attachments.length > 0 - ) { - callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[4] : `Copying attachments for ${file.basename}`); - await writer.ensureFolder(`${assetsRoot}/assets`); - if (this.cancelled) { - return this.cancelledResult(outputRoot, completedFiles, files.length); - } - for (const att of doc.attachments) { - if (this.cancelled) { - return this.cancelledResult(outputRoot, completedFiles, files.length); - } - if (copiedAttachments.has(att.outputRelativePath)) continue; - copiedAttachments.add(att.outputRelativePath); - try { - await writer.copyBinaryFile( - att.sourcePath, - `${assetsRoot}/${att.outputRelativePath}`, - ); - } catch { - allWarnings.push(`Failed to copy attachment: ${att.sourcePath}`); + allWarnings.push(...formatWarnings); + if (this.cancelled) break; + + // Step 6: Copy attachments (deduplicate across files) — not for EPUB, + // whose images are packaged inside the .epub itself. + if ( + settings.copyAttachments + && effectivePlan.profile !== "epub" + && doc.attachments.length > 0 + ) { + callbacks?.onPhase(isSingleFile ? SINGLE_FILE_PHASES[4] : `Copying attachments for ${file.basename}`); + await writer.ensureFolder(`${assetsRoot}/assets`); + if (this.cancelled) break; + + for (const att of doc.attachments) { + if (this.cancelled) break runLoop; + // A path joins copiedAttachments only after a successful copy, + // so a shared attachment that failed for one source is retried + // for a later source that needs it. + if (copiedAttachments.has(att.outputRelativePath)) continue; + try { + await writer.copyBinaryFile( + att.sourcePath, + `${assetsRoot}/${att.outputRelativePath}`, + ); + copiedAttachments.add(att.outputRelativePath); + } catch { + errors.push(`Failed to copy attachment: ${att.sourcePath}`); + hasFailure = true; + // This primary is not complete even though it exists. + attachmentFailed = true; + } } + if (this.cancelled) break; } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`Export failed for ${file.path}: ${msg}`); + hasFailure = true; + break; } - completedFiles++; - callbacks?.onFileComplete(i, files.length); + if (attachmentFailed) continue; + + // All required writes for this file succeeded and cancellation did not + // interrupt it. + incompletePaths.delete(outputFilePath); + completedPaths.push(outputFilePath); + callbacks?.onFileComplete(completedPaths.length - 1, files.length); } - // Write export report. Reports never overwrite anything, including - // prior reports, even when the main export allows overwrite. - if (allWarnings.length > 0) { - const report = allWarnings - .map((w, i) => `${i + 1}. ${w}`) - .join("\n"); + if (this.cancelled) { + const completed = completedPaths.length; + const total = requestedPaths.length; + allWarnings.push( + total === 1 + ? "Export was cancelled." + : `Export was cancelled. ${completed} of ${total} file(s) exported.`, + ); + } + const status = resolveExportStatus( + this.cancelled, completedPaths.length, requestedPaths.length, hasFailure, + ); + + // Write a report only when the run reached its output-writing stage and + // that directory actually exists; preflight failures create nothing. + if ( + (allWarnings.length > 0 || errors.length > 0) + && enteredOutputStage + && writer.folderExists(assetsRoot) + ) { const reportWriter = new OutputWriter(this.app, false); try { - await reportWriter.writeText( - this.reportPath(assetsRoot, effectivePlan, reportWriter), - `# Export Warnings\n\n${report}\n`, - ); + const path = this.reportPath(assetsRoot, effectivePlan, reportWriter); + await reportWriter.writeText(path, this.reportContent(status, plan, { + completedPaths, + incompletePaths: [...incompletePaths], + warnings: allWarnings, + errors, + })); + reportPath = path; } catch (err) { const msg = err instanceof Error ? err.message : String(err); allWarnings.push(`Could not write export report: ${msg}`); @@ -280,23 +315,65 @@ export class ExportRunner { } return { - success: true, - outputRoot: effectivePlan.outputRoot, - warnings: allWarnings, + status, success: status === "completed", outputRoot: assetsRoot, + totalFiles: requestedPaths.length, + completedFiles: completedPaths.length, + completedPaths, incompletePaths: [...incompletePaths], + warnings: allWarnings, errors, + ...(reportPath ? { reportPath } : {}), }; } - private cancelledResult(outputRoot: string, completed: number, total: number): ExportResult { - const msg = total === 1 - ? "Export was cancelled." - : `Export was cancelled. ${completed} of ${total} file(s) exported.`; + private failedPreflight(plan: ExportPlan, errors: string[]): ExportResult { return { - success: completed > 0, - outputRoot, - warnings: [msg], + status: "failed", + success: false, + outputRoot: plan.outputRoot, + totalFiles: plan.inputFiles.length, + completedFiles: 0, + completedPaths: [], + incompletePaths: [], + warnings: [], + errors, }; } + private reportContent( + status: string, + plan: ExportPlan, + outcome: { + completedPaths: string[]; + incompletePaths: string[]; + warnings: string[]; + errors: string[]; + }, + ): string { + const lines = [ + "# Export Report", + "", + `Status: ${status}`, + `Files: ${outcome.completedPaths.length} of ${plan.inputFiles.length} complete`, + ]; + if (outcome.completedPaths.length > 0) { + lines.push("", "## Completed", ...outcome.completedPaths.map((p) => `- ${p}`)); + } + if (outcome.incompletePaths.length > 0) { + lines.push( + "", "## Possibly incomplete", + ...outcome.incompletePaths.map((p) => `- ${p}`), + "", + "These outputs may exist but are not complete.", + ); + } + if (outcome.warnings.length > 0) { + lines.push("", "## Warnings", ...numbered(outcome.warnings)); + } + if (outcome.errors.length > 0) { + lines.push("", "## Errors", ...numbered(outcome.errors)); + } + return `${lines.join("\n")}\n`; + } + private containsUnexpandedNoteEmbed( markdown: string, sourcePath: string, @@ -381,3 +458,7 @@ export class ExportRunner { return candidate; } } + +function numbered(items: string[]): string[] { + return items.map((item, i) => `${i + 1}. ${item}`); +} From 23e670ad2f1fdf046207b3b5179691f6c5c60b9e Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:35:39 +0800 Subject: [PATCH 2/5] fix: distinguish export completion cancellation and failure --- src/export/ExportOutcome.test.ts | 2 +- src/main.ts | 13 +---- src/ui/ExportResultMessage.test.ts | 90 ++++++++++++++++++++++++++++++ src/ui/ExportResultMessage.ts | 24 ++++++++ 4 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/ui/ExportResultMessage.test.ts create mode 100644 src/ui/ExportResultMessage.ts diff --git a/src/export/ExportOutcome.test.ts b/src/export/ExportOutcome.test.ts index d7c072b..7a50f39 100644 --- a/src/export/ExportOutcome.test.ts +++ b/src/export/ExportOutcome.test.ts @@ -213,7 +213,7 @@ describe("export outcomes", () => { fixture.putText("a/A.md", "Link to [[missing]]"); const vault = fixture.app.vault; const create = vault.create.bind(vault) as unknown as - (path: string, content: string) => Promise; + (path: string, content: string) => Promise; vault.create = async (path: string, content: string) => { if (path.endsWith("export-report.md")) { throw new Error("simulated report failure"); diff --git a/src/main.ts b/src/main.ts index bf02b4b..3d56a46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,8 @@ import { ExportModal, ExportModalResult } from "@/ui/ExportModal"; import { ExportSourceResolver } from "@/export/ExportSourceResolver"; import { ExportPlanBuilder, validatePlan } from "@/export/ExportPlan"; import { ExportRunner, ExportProgressCallbacks, SINGLE_FILE_PHASES } from "@/export/ExportRunner"; -import { ProgressNotice, summarizeWarnings } from "@/ui/ProgressNotice"; +import { ProgressNotice } from "@/ui/ProgressNotice"; +import { exportResultMessage } from "@/ui/ExportResultMessage"; type NotebookNavigatorMenus = { registerFileMenu?: (callback: (context: NotebookNavigatorFileContext) => void) => () => void; @@ -215,15 +216,7 @@ export default class DocumentExporterPlugin extends Plugin { } const exportResult = await runner.run(plan, this.settings, callbacks); - - if (exportResult.success) { - const msg = exportResult.warnings.length > 0 - ? `Export complete with ${exportResult.warnings.length} warning(s): ${exportResult.outputRoot} — ${summarizeWarnings(exportResult.warnings)}` - : `Export complete: ${exportResult.outputRoot}`; - progress.finish(msg); - } else { - progress.finish(`Export failed: ${exportResult.warnings.join(", ")}`); - } + progress.finish(exportResultMessage(exportResult)); } catch (err) { const message = err instanceof Error ? err.message : String(err); progress.finish(`Export error: ${message}`); diff --git a/src/ui/ExportResultMessage.test.ts b/src/ui/ExportResultMessage.test.ts new file mode 100644 index 0000000..4f97963 --- /dev/null +++ b/src/ui/ExportResultMessage.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { exportResultMessage } from "@/ui/ExportResultMessage"; +import type { ExportResult } from "@/export/ExportOutcome"; + +function result(overrides: Partial): ExportResult { + return { + status: "completed", + success: true, + outputRoot: "exports/notes", + totalFiles: 3, + completedFiles: 3, + completedPaths: [], + incompletePaths: [], + warnings: [], + errors: [], + ...overrides, + }; +} + +describe("exportResultMessage", () => { + it("labels a completed run with counts and output root", () => { + expect(exportResultMessage(result({}))) + .toBe("Export complete: 3/3 file(s) complete — exports/notes"); + }); + + it("shows a warning for a completed run without a retry hint", () => { + const message = exportResultMessage(result({ + warnings: ["Unresolved link: missing"], + reportPath: "exports/notes/export-report.md", + })); + expect(message).toContain("Export complete: 3/3 file(s) complete"); + expect(message).toContain("Unresolved link: missing"); + expect(message).toContain("Details: exports/notes/export-report.md"); + expect(message).not.toContain("Existing output was kept"); + }); + + it("describes a partial run with incomplete outputs, report and retry hint", () => { + const message = exportResultMessage(result({ + status: "partial", + success: false, + completedFiles: 1, + incompletePaths: ["exports/notes/B.md"], + errors: ["Failed to copy attachment: notes/img.png"], + reportPath: "exports/notes/export-report-2.md", + })); + expect(message).toContain("Export partially complete: 1/3 file(s) complete"); + expect(message).toContain("1 output(s) may be incomplete"); + expect(message).toContain("Failed to copy attachment: notes/img.png"); + expect(message).toContain("Details: exports/notes/export-report-2.md"); + expect(message).toContain("Existing output was kept"); + }); + + it("prefers the first error over warnings", () => { + const message = exportResultMessage(result({ + status: "failed", + success: false, + completedFiles: 0, + errors: ["Export failed for a.md: boom"], + warnings: ["earlier warning"], + })); + expect(message).toContain("Export failed: 0/3 file(s) complete"); + expect(message).toContain("Export failed for a.md: boom"); + expect(message).not.toContain("earlier warning"); + }); + + it("reports the all-zero cancelled case without claiming failure", () => { + const message = exportResultMessage(result({ + status: "cancelled", + success: false, + completedFiles: 0, + })); + expect(message).toContain("Export cancelled: 0/3 file(s) complete"); + expect(message.startsWith("Export complete")).toBe(false); + expect(message.startsWith("Export failed")).toBe(false); + }); + + it("keeps cancelled runs distinguishable from complete and failed runs", () => { + const cancelled = exportResultMessage(result({ + status: "cancelled", + success: false, + completedFiles: 1, + completedPaths: ["exports/notes/a.md"], + warnings: ["Export was cancelled. 1 of 3 file(s) exported."], + })); + expect(cancelled).toContain("Export cancelled: 1/3 file(s) complete"); + expect(cancelled.startsWith("Export complete")).toBe(false); + expect(cancelled.startsWith("Export failed")).toBe(false); + expect(cancelled).toContain("Existing output was kept"); + }); +}); diff --git a/src/ui/ExportResultMessage.ts b/src/ui/ExportResultMessage.ts new file mode 100644 index 0000000..e44821e --- /dev/null +++ b/src/ui/ExportResultMessage.ts @@ -0,0 +1,24 @@ +import type { ExportResult } from "@/export/ExportOutcome"; + +export function exportResultMessage(result: ExportResult): string { + const labels = { + completed: "Export complete", + partial: "Export partially complete", + cancelled: "Export cancelled", + failed: "Export failed", + } as const; + const pieces = [ + `${labels[result.status]}: ${result.completedFiles}/${result.totalFiles} file(s) complete`, + result.outputRoot, + ]; + if (result.incompletePaths.length) { + pieces.push(`${result.incompletePaths.length} output(s) may be incomplete`); + } + const firstDiagnostic = result.errors[0] ?? result.warnings[0]; + if (firstDiagnostic) pieces.push(firstDiagnostic); + if (result.reportPath) pieces.push(`Details: ${result.reportPath}`); + if (result.status !== "completed") { + pieces.push("Existing output was kept. Retry with overwrite off to create a separate export."); + } + return pieces.join(" — "); +} From 30987451367208c09a87b6e53c0941e74d0b6f5f Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:43:53 +0800 Subject: [PATCH 3/5] test: add reproducible release artifact coverage --- package-lock.json | 28 ++ package.json | 1 + scripts/create-release-fixtures.mjs | 111 +++++++ src/export/ReleaseArtifacts.test.ts | 474 ++++++++++++++++++++++++++++ src/formats/html-document.ts | 6 +- src/formats/testZip.ts | 8 +- src/test-support/memory-vault.ts | 13 +- 7 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 scripts/create-release-fixtures.mjs create mode 100644 src/export/ReleaseArtifacts.test.ts diff --git a/package-lock.json b/package-lock.json index 4d85bfa..20377bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.7.4", "license": "MIT", "devDependencies": { + "@types/jsdom": "^30.0.0", "@types/node": "^20.11.0", "esbuild": "^0.20.0", "eslint-plugin-obsidianmd": "^0.4.1", @@ -1443,6 +1444,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^8.9.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.2.tgz", + "integrity": "sha512-7/+aSjzkUoLc92hV22bTW4aGanXf800zbwguhcICs0OAoCF9wDOE4wkopQ+SqfhXZm8mCK8gHpdTs7pZUWzK3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1477,6 +1498,13 @@ "@types/estree": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", diff --git a/package.json b/package.json index f4a2d8b..5357f7a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/rogerdigital/document-exporter" }, "devDependencies": { + "@types/jsdom": "^30.0.0", "@types/node": "^20.11.0", "esbuild": "^0.20.0", "eslint-plugin-obsidianmd": "^0.4.1", diff --git a/scripts/create-release-fixtures.mjs b/scripts/create-release-fixtures.mjs new file mode 100644 index 0000000..854ff17 --- /dev/null +++ b/scripts/create-release-fixtures.mjs @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import path from "node:path"; +import { deflateSync } from "node:zlib"; +import { createHash } from "node:crypto"; + +const destination = process.argv[2]; +if (!destination || process.argv.length !== 3) { + throw new Error("Usage: node scripts/create-release-fixtures.mjs "); +} +const root = path.resolve(destination); +if (fs.existsSync(root) && (!fs.statSync(root).isDirectory() || fs.readdirSync(root).length)) { + throw new Error(`Refusing non-empty or non-directory destination: ${root}`); +} +fs.mkdirSync(root, { recursive: true }); +const manifest = []; +function put(name, data) { + const target = path.join(root, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, data, { flag: "wx" }); + manifest.push({ + path: name, + sha256: createHash("sha256").update(data).digest("hex"), + }); +} +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const label = Buffer.from(type, "ascii"); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE(crc32(Buffer.concat([label, data]))); + return Buffer.concat([length, label, data, checksum]); +} +function png(width, height, rgb) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 2; + const pixels = Buffer.alloc(height * (1 + width * 3)); + for (let y = 0; y < height; y++) { + const row = y * (1 + width * 3); + for (let x = 0; x < width; x++) { + const offset = row + 1 + x * 3; + pixels[offset] = rgb[0]; + pixels[offset + 1] = rgb[1]; + pixels[offset + 2] = rgb[2]; + } + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(pixels)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +put("images/landscape.png", png(640, 240, [210, 30, 30])); +put("images/portrait.png", png(240, 640, [30, 30, 210])); +put("collision/a/img.png", png(160, 100, [210, 30, 30])); +put("collision/b/img.png", png(160, 100, [30, 30, 210])); +put("collision/a/A.md", "# Collision A\n\nRed image:\n\n![[img.png]]\n"); +put("collision/b/B.md", "# Collision B\n\nBlue image:\n\n![[img.png]]\n"); +put("content.md", [ + "---", "title: Release acceptance", "---", "# Release acceptance", "", + "BEGIN-CONTENT 中文导出 😀 café", "", + "## Heading two", "### Heading three", "#### Heading four", + "##### Heading five", "###### Heading six", "", + "**Bold** and *italic* and `inline code`.", "", + "1. Ordered one", "2. Ordered two", "", "- Bullet one", "- Bullet two", "", + "| Name | Value |", "| --- | --- |", "| Alpha | 123 |", "| 中文 | 456 |", "", + "```ts", "const sentinel = 'CODE-CONTENT';", "```", "", + "[External link](https://example.com/)", "", + "![Landscape](images/landscape.png)", "", + "![Portrait](images/portrait.png)", "", "END-CONTENT", "", +].join("\n")); +put("folder/index.md", "# Folder index\n\n[[nested/part]]\n\n![[nested/part]]\n\nEND-INDEX\n"); +put("folder/nested/part.md", "# Nested part\n\nEMBED-SENTINEL\n\n![Local](../../images/landscape.png)\n\n[[../index]]\n"); +put("folder/nested/third.md", "# Third\n\nTHIRD-SENTINEL\n\n[[part]]\n"); +put("heading-host.md", "# Heading host\n\nBefore\n\n![[heading-source#Wanted]]\n\nAfter\n"); +put("heading-source.md", "# Source\n\n## Wanted\n\nWANTED-SENTINEL\n\n## Excluded\n\nEXCLUDED-SENTINEL\n"); +put("adjacency.md", "![[images/landscape.png]]\n## After image\n\nAFTER-IMAGE-SENTINEL\n"); +put("export-report.md", "# Preserve this document\n\nREPORT-DOCUMENT-SENTINEL\n\n[[MissingReportTarget]]\n"); +put("failure/missing.md", "# Missing references\n\n![[NoSuchImage.png]]\n\n[[NoSuchNote]]\n"); +put("failure/cycle-a.md", "# Cycle A\n\n![[cycle-b]]\n"); +put("failure/cycle-b.md", "# Cycle B\n\n![[cycle-a]]\n"); +put("limitations.md", [ + "# Documented limitations", "", "![[heading-source#^absent-block]]", "", + "```dataview", 'LIST FROM "folder"', "```", "", + "- [ ] Task item", "", "> [!note] Callout", "> CALLOUT-SENTINEL", "", + "$$x^2 + y^2 = z^2$$", "", "```mermaid", "graph LR", "A-->B", "```", "", +].join("\n")); +put("long.md", "# Long document\n\n" + Array.from({ length: 120 }, (_, i) => + `## Section ${i + 1}\n\nPAGE-SENTINEL-${i + 1} 中文 long document.\n\n` + + "| Column A | Column B |\n| --- | --- |\n| Left | Right |\n\n" +).join("")); +for (let i = 1; i <= 501; i++) { + const name = String(i).padStart(3, "0"); + put(`bulk/note-${name}.md`, `# Bulk ${name}\n\nBULK-SENTINEL-${name}\n`); +} +fs.writeFileSync(path.join(root, "fixture-manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" }); +process.stdout.write(`Created ${manifest.length} synthetic fixture files in ${root}\n`); diff --git a/src/export/ReleaseArtifacts.test.ts b/src/export/ReleaseArtifacts.test.ts new file mode 100644 index 0000000..454c924 --- /dev/null +++ b/src/export/ReleaseArtifacts.test.ts @@ -0,0 +1,474 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { deflateSync } from "node:zlib"; +import { createHash } from "node:crypto"; +import { Buffer } from "node:buffer"; +import { env as nodeEnv } from "node:process"; +import * as nodeFs from "node:fs"; +import * as nodePath from "node:path"; +import { JSDOM } from "jsdom"; +import { ExportRunner } from "@/export/ExportRunner"; +import { ExportPlanBuilder } from "@/export/ExportPlan"; +import { DEFAULT_SETTINGS, ExportSettings, ExportSource, ExportProfileId } from "@/types"; +import { createMemoryVault } from "@/test-support/memory-vault"; +import { readStoredZipEntry, readStoredZipEntryBytes } from "@/formats/testZip"; + +// Headless contract suite: the real plan → runner → collector → rewriter → +// renderer → writer pipeline against a persistent in-memory vault. HTML runs +// through the basic/fallback converter here (no Obsidian DOM), and no PDF +// success claim is possible in this environment. Fixture notes mirror the +// marker text of scripts/create-release-fixtures.mjs so native acceptance can +// compare against identical content. + +const RED = [210, 30, 30] as const; +const BLUE = [30, 30, 210] as const; + +function png(width: number, height: number, rgb: readonly [number, number, number]): Uint8Array { + const crc32 = (bytes: Uint8Array): number => { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; + }; + const chunk = (type: string, data: Buffer): Buffer => { + const label = Buffer.from(type, "ascii"); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE(crc32(Buffer.concat([label, data]))); + return Buffer.concat([length, label, data, checksum]); + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 2; + const pixels = Buffer.alloc(height * (1 + width * 3)); + for (let y = 0; y < height; y++) { + const row = y * (1 + width * 3); + pixels[row] = 0; + for (let x = 0; x < width; x++) { + const offset = row + 1 + x * 3; + pixels[offset] = rgb[0]; + pixels[offset + 1] = rgb[1]; + pixels[offset + 2] = rgb[2]; + } + } + return new Uint8Array(Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(pixels)), + chunk("IEND", Buffer.alloc(0)), + ])); +} + +function sha256Hex(data: Uint8Array): string { + return createHash("sha256").update(data).digest("hex"); +} + +function buildFixtureVault() { + const fixture = createMemoryVault(); + fixture.putText("content.md", [ + "---", "title: Release acceptance", "---", "# Release acceptance", "", + "BEGIN-CONTENT 中文导出 😀 café", "", + "## Heading two", "### Heading three", "#### Heading four", + "##### Heading five", "###### Heading six", "", + "**Bold** and *italic* and `inline code`.", "", + "1. Ordered one", "2. Ordered two", "", "- Bullet one", "- Bullet two", "", + "| Name | Value |", "| --- | --- |", "| Alpha | 123 |", "| 中文 | 456 |", "", + "```ts", "const sentinel = 'CODE-CONTENT';", "```", "", + "[External link](https://example.com/)", "", + "![Landscape](images/landscape.png)", "", + "![Portrait](images/portrait.png)", "", "END-CONTENT", "", + ].join("\n")); + fixture.putBinary("images/landscape.png", png(640, 240, RED)); + fixture.putBinary("images/portrait.png", png(240, 640, BLUE)); + fixture.putText("folder/index.md", "# Folder index\n\n[[nested/part]]\n\n![[nested/part]]\n\nEND-INDEX\n"); + fixture.putText("folder/nested/part.md", "# Nested part\n\nEMBED-SENTINEL\n\n![Local](../../images/landscape.png)\n\n[[../index]]\n"); + fixture.putText("folder/nested/third.md", "# Third\n\nTHIRD-SENTINEL\n\n[[part]]\n"); + fixture.putText("collision/a/A.md", "# Collision A\n\nRed image:\n\n![[img.png]]\n"); + fixture.putText("collision/b/B.md", "# Collision B\n\nBlue image:\n\n![[img.png]]\n"); + fixture.putBinary("collision/a/img.png", png(160, 100, RED)); + fixture.putBinary("collision/b/img.png", png(160, 100, BLUE)); + fixture.putText("export-report.md", "# Preserve this document\n\nREPORT-DOCUMENT-SENTINEL\n\n[[MissingReportTarget]]\n"); + return fixture; +} + +type FixtureVault = ReturnType; + +const SETTINGS: ExportSettings = { + ...DEFAULT_SETTINGS, + expandEmbeds: true, + copyAttachments: true, + overwriteExisting: false, +}; + +function runExport( + fixture: FixtureVault, + source: ExportSource, + profile: ExportProfileId, + outputFilename: string, + outputFolderName?: string, + callbacks?: Parameters[2], +) { + const plan = new ExportPlanBuilder( + fixture.app, source, profile, "exports", outputFilename, outputFolderName, + ).setInputFiles( + source.type === "folder" + ? ["folder/index.md", "folder/nested/part.md", "folder/nested/third.md"] + : [source.type === "current-file" ? source.path : source.paths[0]], + ).build(); + return new ExportRunner(fixture.app).run(plan, SETTINGS, callbacks); +} + +function parseXml(text: string): Document { + const dom = new JSDOM(text, { contentType: "text/xml" }); + const errors = dom.window.document.querySelectorAll("parsererror"); + if (errors.length > 0) { + throw new Error(`XML parse error in: ${errors[0].textContent?.slice(0, 200)}`); + } + return dom.window.document; +} + +function elementsNamed(doc: Document, localName: string): Element[] { + const all = doc.querySelectorAll("*"); + const matched: Element[] = []; + all.forEach((el) => { + if (el.localName === localName) matched.push(el); + }); + return matched; +} + +// Optional artifact persistence: only this test file writes outputs, only +// when RELEASE_ARTIFACT_DIR names a new or empty directory. Ordinary CI runs +// leave nothing on disk. +const artifactRoot = nodeEnv.RELEASE_ARTIFACT_DIR; +const artifactIndex: Record[] = []; + +beforeAll(() => { + if (!artifactRoot) return; + if (nodeFs.existsSync(artifactRoot)) { + if (!nodeFs.statSync(artifactRoot).isDirectory() || nodeFs.readdirSync(artifactRoot).length > 0) { + throw new Error(`RELEASE_ARTIFACT_DIR must be a new or empty directory: ${artifactRoot}`); + } + } else { + nodeFs.mkdirSync(artifactRoot, { recursive: true }); + } +}); + +afterAll(() => { + if (!artifactRoot) return; + nodeFs.writeFileSync( + nodePath.join(artifactRoot, "index.json"), + JSON.stringify(artifactIndex, null, 2) + "\n", + { flag: "wx" }, + ); +}); + +function persistCase( + caseName: string, + fixture: FixtureVault, + result: { outputRoot: string; status: string; warnings: string[]; errors: string[] }, + rendering: string, +) { + if (!artifactRoot) return; + const caseDir = nodePath.join(artifactRoot, caseName); + nodeFs.mkdirSync(caseDir); + const files: { path: string; sha256: string }[] = []; + for (const p of fixture.paths()) { + if (!p.startsWith(`${result.outputRoot}/`) || !fixture.isFile(p)) continue; + const relative = p.slice(result.outputRoot.length + 1); + const target = nodePath.join(caseDir, relative); + nodeFs.mkdirSync(nodePath.dirname(target), { recursive: true }); + let buffer: Buffer; + try { + buffer = Buffer.from(fixture.text(p), "utf8"); + } catch { + buffer = Buffer.from(fixture.bytes(p)); + } + nodeFs.writeFileSync(target, buffer, { flag: "wx" }); + files.push({ path: relative, sha256: sha256Hex(new Uint8Array(buffer)) }); + } + artifactIndex.push({ + case: caseName, + rendering, + settings: SETTINGS, + status: result.status, + outputRoot: result.outputRoot, + warnings: result.warnings, + errors: result.errors, + files, + }); +} + +describe("release artifacts (headless)", () => { + it("exports content.md to Markdown with markers, table, code and identical image bytes", async () => { + const fixture = buildFixtureVault(); + const result = await runExport( + fixture, { type: "current-file", path: "content.md" }, "markdown-bundle", "content", + ); + + expect(result.status).toBe("completed"); + const markdown = fixture.text("exports/content.md"); + expect(markdown).toContain("BEGIN-CONTENT 中文导出 😀 café"); + expect(markdown).toContain("END-CONTENT"); + expect(markdown).toContain("| Alpha | 123 |"); + expect(markdown).toContain("| 中文 | 456 |"); + expect(markdown).toContain("CODE-CONTENT"); + expect(markdown).toContain("assets/landscape.png"); + expect(markdown).toContain("assets/portrait.png"); + expect(fixture.bytes("exports/assets/landscape.png")) + .toEqual(fixture.bytes("images/landscape.png")); + expect(fixture.bytes("exports/assets/portrait.png")) + .toEqual(fixture.bytes("images/portrait.png")); + persistCase("content-markdown", fixture, result, "headless:markdown-bundle"); + }); + + it("exports content.md to fallback HTML with parseable tables and resolvable images", async () => { + const fixture = buildFixtureVault(); + const result = await runExport( + fixture, { type: "current-file", path: "content.md" }, "html-document", "content", + ); + + expect(result.status).toBe("completed"); + const dom = new JSDOM(fixture.text("exports/content.html")); + const doc = dom.window.document; + expect(doc.body.textContent).toContain("BEGIN-CONTENT 中文导出 😀 café"); + expect(doc.body.textContent).toContain("END-CONTENT"); + expect(doc.body.textContent).toContain("CODE-CONTENT"); + + const rowTexts: string[] = []; + doc.querySelectorAll("table tr").forEach((row) => rowTexts.push(row.textContent ?? "")); + expect(rowTexts.some((row) => row.includes("Alpha") && row.includes("123"))).toBe(true); + expect(rowTexts.some((row) => row.includes("中文") && row.includes("456"))).toBe(true); + + const imageSources: string[] = []; + doc.querySelectorAll("img").forEach((img) => imageSources.push(img.getAttribute("src") ?? "")); + expect(imageSources).toContain("assets/landscape.png"); + expect(imageSources).toContain("assets/portrait.png"); + for (const src of imageSources) { + expect(fixture.isFile(`exports/${src}`)).toBe(true); + } + persistCase("content-html-fallback", fixture, result, "headless-fallback:html-document"); + }); + + it("exports content.md to DOCX with valid XML, markers, relationships and embedded images", async () => { + const fixture = buildFixtureVault(); + const result = await runExport( + fixture, { type: "current-file", path: "content.md" }, "docx", "content", + ); + + expect(result.status).toBe("completed"); + const zip = fixture.bytes("exports/content.docx"); + + const documentXml = readStoredZipEntry(zip, "word/document.xml"); + parseXml(documentXml); + parseXml(readStoredZipEntry(zip, "[Content_Types].xml")); + for (const marker of [ + "BEGIN-CONTENT", "END-CONTENT", "CODE-CONTENT", + "Alpha", "123", "中文", "456", + ]) { + expect(documentXml).toContain(marker); + } + + const rels = readStoredZipEntry(zip, "word/_rels/document.xml.rels"); + parseXml(rels); + expect(rels).toContain('Target="media/image1.png"'); + expect(rels).toContain('Target="media/image2.png"'); + expect(rels).toContain('Target="https://example.com/"'); + expect(rels).toContain('TargetMode="External"'); + + expect(readStoredZipEntryBytes(zip, "word/media/image1.png")) + .toEqual(fixture.bytes("images/landscape.png")); + expect(readStoredZipEntryBytes(zip, "word/media/image2.png")) + .toEqual(fixture.bytes("images/portrait.png")); + persistCase("content-docx", fixture, result, "headless:docx"); + }); + + it("exports content.md to EPUB with resolvable spine, markers, identical image bytes and no app:// references", async () => { + const fixture = buildFixtureVault(); + const result = await runExport( + fixture, { type: "current-file", path: "content.md" }, "epub", "content", + ); + + expect(result.status).toBe("completed"); + const zip = fixture.bytes("exports/content.epub"); + expect(readStoredZipEntry(zip, "mimetype")).toBe("application/epub+zip"); + + const container = parseXml(readStoredZipEntry(zip, "META-INF/container.xml")); + const rootFiles = elementsNamed(container, "rootfile"); + expect(rootFiles.length).toBeGreaterThan(0); + const packagePath = rootFiles[0].getAttribute("full-path"); + expect(packagePath).toBe("OEBPS/content.opf"); + + const opf = parseXml(readStoredZipEntry(zip, packagePath ?? "OEBPS/content.opf")); + const manifestIds = new Set( + elementsNamed(opf, "item").map((item) => item.getAttribute("id")), + ); + const spineIdrefs = elementsNamed(opf, "itemref") + .map((ref) => ref.getAttribute("idref")) + .filter((id): id is string => id !== null); + expect(spineIdrefs.length).toBeGreaterThan(0); + expect(spineIdrefs).toContain("nav"); + expect(spineIdrefs).toContain("ch1"); + for (const idref of spineIdrefs) { + expect(manifestIds.has(idref)).toBe(true); + } + + const nav = parseXml(readStoredZipEntry(zip, "OEBPS/nav.xhtml")); + expect(elementsNamed(nav, "nav").length).toBeGreaterThan(0); + const chapter = readStoredZipEntry(zip, "OEBPS/chapter-1.xhtml"); + parseXml(chapter); + expect(chapter).toContain("BEGIN-CONTENT"); + expect(chapter).toContain("END-CONTENT"); + expect(chapter).toContain("CODE-CONTENT"); + + expect(readStoredZipEntryBytes(zip, "OEBPS/images/image-1.png")) + .toEqual(fixture.bytes("images/landscape.png")); + expect(readStoredZipEntryBytes(zip, "OEBPS/images/image-2.png")) + .toEqual(fixture.bytes("images/portrait.png")); + + expect(chapter).not.toContain("app://"); + expect(readStoredZipEntry(zip, "OEBPS/content.opf")).not.toContain("app://"); + persistCase("content-epub", fixture, result, "headless:epub"); + }); + + describe("folder batch", () => { + const folderSource: ExportSource = { type: "folder", path: "folder", recursive: true }; + + it("preserves nested primaries, relative links and shared attachment bytes in Markdown", async () => { + const fixture = buildFixtureVault(); + const result = await runExport( + fixture, folderSource, "markdown-bundle", "index", "folder", + ); + + expect(result.status).toBe("completed"); + expect(result.completedFiles).toBe(3); + for (const primary of [ + "exports/folder/index.md", + "exports/folder/nested/part.md", + "exports/folder/nested/third.md", + ]) { + expect(fixture.isFile(primary)).toBe(true); + } + + const index = fixture.text("exports/folder/index.md"); + expect(index).toContain("END-INDEX"); + // The expanded embed carries EMBED-SENTINEL; links stay relative. + expect(index).toContain("EMBED-SENTINEL"); + expect(index).toContain("(nested/part.md)"); + expect(index).toContain("assets/landscape.png"); + + const part = fixture.text("exports/folder/nested/part.md"); + expect(part).toContain("EMBED-SENTINEL"); + expect(part).toContain("../assets/landscape.png"); + expect(part).toContain("(../index.md)"); + + const third = fixture.text("exports/folder/nested/third.md"); + expect(third).toContain("THIRD-SENTINEL"); + expect(third).toContain("(part.md)"); + + expect(fixture.bytes("exports/folder/assets/landscape.png")) + .toEqual(fixture.bytes("images/landscape.png")); + persistCase("folder-markdown", fixture, result, "headless:markdown-bundle"); + }); + + it("preserves nested primaries and relative links in fallback HTML", async () => { + const fixture = buildFixtureVault(); + const result = await runExport( + fixture, folderSource, "html-document", "index", "folder", + ); + + expect(result.status).toBe("completed"); + const dom = new JSDOM(fixture.text("exports/folder/index.html")); + const doc = dom.window.document; + expect(doc.body.textContent).toContain("END-INDEX"); + expect(doc.body.textContent).toContain("EMBED-SENTINEL"); + const link = doc.querySelector('a[href="nested/part.html"]'); + expect(link).not.toBeNull(); + expect(fixture.isFile("exports/folder/nested/part.html")).toBe(true); + + const partDom = new JSDOM(fixture.text("exports/folder/nested/part.html")); + const image = partDom.window.document.querySelector('img[src="../assets/landscape.png"]'); + expect(image).not.toBeNull(); + expect(fixture.isFile("exports/folder/assets/landscape.png")).toBe(true); + persistCase("folder-html-fallback", fixture, result, "headless-fallback:html-document"); + }); + }); + + it("keeps collision A intact while B exports its blue image to a separate root", async () => { + const fixture = buildFixtureVault(); + const run = (path: string, name: string) => runExport( + fixture, { type: "current-file", path }, "markdown-bundle", name, + ); + + const first = await run("collision/a/A.md", "A"); + const originalDocument = fixture.text("exports/A.md"); + const originalHash = sha256Hex(fixture.bytes("exports/assets/img.png")); + const second = await run("collision/b/B.md", "B"); + + expect(first.status).toBe("completed"); + expect(second.status).toBe("completed"); + expect(fixture.text("exports/A.md")).toBe(originalDocument); + expect(sha256Hex(fixture.bytes("exports/assets/img.png"))).toBe(originalHash); + expect(second.outputRoot).not.toBe("exports"); + expect(fixture.bytes(`${second.outputRoot}/assets/img.png`)) + .toEqual(fixture.bytes("collision/b/img.png")); + expect(fixture.text(`${second.outputRoot}/B.md`)).toContain("assets/img.png"); + }); + + it("keeps a primary named export-report.md and writes warnings to a distinct report", async () => { + const fixture = buildFixtureVault(); + const result = await runExport( + fixture, { type: "current-file", path: "export-report.md" }, "markdown-bundle", "export-report", + ); + + expect(result.status).toBe("completed"); + expect(fixture.text("exports/export-report.md")).toContain("REPORT-DOCUMENT-SENTINEL"); + expect(fixture.isFile("exports/export-report-2.md")).toBe(true); + expect(fixture.text("exports/export-report-2.md")).toContain("Unresolved link: MissingReportTarget"); + expect(result.reportPath).toBe("exports/export-report-2.md"); + }); + + it("reports a missing required attachment as failed, never completed", async () => { + const fixture = buildFixtureVault(); + const callbacks = { + onFileStart: () => {}, + onFileComplete: () => {}, + onPhase: (phase: string) => { + if (phase === "Copying attachments") fixture.remove("images/landscape.png"); + }, + }; + const result = await runExport( + fixture, { type: "current-file", path: "content.md" }, "markdown-bundle", "content", + undefined, callbacks, + ); + + expect(result.status).toBe("failed"); + expect(result.success).toBe(false); + expect(result.incompletePaths).toContain("exports/content.md"); + expect(result.errors).toContain("Failed to copy attachment: images/landscape.png"); + }); + + it("reports cancellation with the incomplete primary instead of success", async () => { + const fixture = buildFixtureVault(); + const runner = new ExportRunner(fixture.app); + const plan = new ExportPlanBuilder( + fixture.app, { type: "current-file", path: "content.md" }, + "markdown-bundle", "exports", "content", + ).setInputFiles(["content.md"]).build(); + const result = await runner.run(plan, SETTINGS, { + onFileStart: () => {}, + onFileComplete: () => {}, + onPhase: (phase) => phase === "Copying attachments" && runner.cancel(), + }); + + expect(result.status).toBe("cancelled"); + expect(result.success).toBe(false); + expect(result.completedFiles).toBe(0); + expect(result.incompletePaths).toContain("exports/content.md"); + }); +}); diff --git a/src/formats/html-document.ts b/src/formats/html-document.ts index a37d3bf..310b301 100644 --- a/src/formats/html-document.ts +++ b/src/formats/html-document.ts @@ -26,7 +26,11 @@ export async function renderHtmlDocument( const { html: body, warnings: renderWarnings } = await renderSections(doc.sections, app, doc.title, doc.attachments); warnings.push(...renderWarnings); - const customCss = app ? extractObsidianStyles() : null; + // Style extraction needs Obsidian's live DOM; the headless fallback path + // (no activeDocument global) renders with the default stylesheet instead. + const customCss = app && typeof activeDocument !== "undefined" + ? extractObsidianStyles() + : null; const html = buildHtmlDoc(doc.title, toc, body, customCss); const resolvedOutput = outputFilePath ?? `${plan.outputRoot}/${plan.outputFilename.replace(/\.(md|html|htm)$/i, '')}.html`; diff --git a/src/formats/testZip.ts b/src/formats/testZip.ts index 3991e2d..e5ff9e9 100644 --- a/src/formats/testZip.ts +++ b/src/formats/testZip.ts @@ -1,5 +1,9 @@ export function readStoredZipEntry(data: Uint8Array, targetName: string): string { const decoder = new TextDecoder(); + return decoder.decode(readStoredZipEntryBytes(data, targetName)); +} + +export function readStoredZipEntryBytes(data: Uint8Array, targetName: string): Uint8Array { let offset = 0; while (offset + 30 <= data.byteLength) { @@ -24,12 +28,12 @@ export function readStoredZipEntry(data: Uint8Array, targetName: string): string throw new Error(`Invalid ZIP entry bounds for ${targetName}`); } - const name = decoder.decode(data.slice(nameStart, nameEnd)); + const name = new TextDecoder().decode(data.slice(nameStart, nameEnd)); if (name === targetName) { if (compressionMethod !== 0) { throw new Error(`Expected stored ZIP entry for ${targetName}`); } - return decoder.decode(data.slice(contentStart, contentEnd)); + return data.slice(contentStart, contentEnd); } offset = contentEnd; diff --git a/src/test-support/memory-vault.ts b/src/test-support/memory-vault.ts index 39b41f2..c54c904 100644 --- a/src/test-support/memory-vault.ts +++ b/src/test-support/memory-vault.ts @@ -16,6 +16,7 @@ export interface MemoryVaultFixture { bytes(path: string): Uint8Array; paths(): string[]; remove(path: string): void; + isFile(path: string): boolean; } interface FolderNode extends TFolder { @@ -185,11 +186,15 @@ export function createMemoryVault(): MemoryVaultFixture { return parseCache(content); }, getFirstLinkpathDest: (linkpath: string, sourcePath: string): TFile | null => { - const target = normalizePath(linkpath.split("#")[0].split("|")[0]); + const target = linkpath.split("#")[0].split("|")[0]; if (!target) return null; + // Join with the source folder before normalizing so ../ targets keep + // their upward reference, matching Obsidian's resolution order. const separator = sourcePath.lastIndexOf("/"); const dir = separator === -1 ? "" : sourcePath.slice(0, separator); - const candidates = dir ? [`${dir}/${target}`, target] : [target]; + const candidates = dir + ? [normalizePath(`${dir}/${target}`), normalizePath(target)] + : [normalizePath(target)]; for (const candidate of candidates) { const withExtension = candidate.toLowerCase().endsWith(".md") ? candidate @@ -216,6 +221,10 @@ export function createMemoryVault(): MemoryVaultFixture { return new Uint8Array(cloneBuffer(content)); }, paths: () => [...nodes.keys()].sort(), + isFile: (path) => { + const node = nodes.get(normalizePath(path)); + return node !== undefined && "extension" in node; + }, remove: (path) => { const normalized = normalizePath(path); const node = nodes.get(normalized); From 064f518e50e798d936bc4013b6efa4ec33d922a7 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:45:33 +0800 Subject: [PATCH 4/5] ci: verify release metadata and tests before publication --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 19 ++++-- CLAUDE.md | 2 + scripts/check-version.mjs | 8 +++ scripts/check-version.test.mjs | 111 +++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 scripts/check-version.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 774bc6c..d0693e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: cache: npm - run: npm ci + - run: node --test scripts/check-version.test.mjs - run: npm run check:version - run: npm run lint:obsidian-warnings - run: npx tsc -noEmit -skipLibCheck diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d280557..56fa196 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,14 +26,23 @@ jobs: - name: Install dependencies run: npm ci + - name: Test version validation + run: node --test scripts/check-version.test.mjs + - name: Verify version metadata env: RELEASE_TAG: ${{ github.ref_name }} run: npm run check:version + - name: Lint + run: npm run lint:obsidian-warnings + - name: Build run: npm run build + - name: Test + run: npm test + - name: Attest build provenance uses: actions/attest-build-provenance@v4 with: @@ -47,7 +56,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | tag="${GITHUB_REF#refs/tags/}" - gh release create "$tag" \ - --title="$tag" \ - --generate-notes \ - main.js manifest.json styles.css + notes="docs/releases/$tag/release-notes.md" + if [ -f "$notes" ]; then + gh release create "$tag" --title="$tag" --notes-file "$notes" main.js manifest.json styles.css + else + gh release create "$tag" --title="$tag" --generate-notes main.js manifest.json styles.css + fi diff --git a/CLAUDE.md b/CLAUDE.md index 30f1c83..97e9de1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,8 @@ src/ - CI has an automatic release workflow triggered by tags — do NOT manually run `gh release create` after pushing a tag, it will conflict - Release steps: bump version in `manifest.json` + `versions.json` → PR → merge → `git tag -a X.Y.Z` → `git push origin X.Y.Z` → CI creates the release with `main.js`, `manifest.json`, `styles.css` +- Version consistency across `package.json`, `package-lock.json`, `manifest.json` and the `versions.json` → `minAppVersion` mapping is enforced by `npm run check:version`; its regression tests run via `node --test scripts/check-version.test.mjs` (CI and release workflow run both) +- Release notes: when `docs/releases//release-notes.md` exists, CI publishes it verbatim; otherwise GitHub generates notes. Write the reviewed file for planned releases ## Key References diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs index ae89194..0dd1115 100644 --- a/scripts/check-version.mjs +++ b/scripts/check-version.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); const manifest = JSON.parse(fs.readFileSync("manifest.json", "utf8")); const versions = JSON.parse(fs.readFileSync("versions.json", "utf8")); +const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8")); const expected = packageJson.version; const errors = []; @@ -19,6 +20,13 @@ if (tag && tag !== expected) { errors.push(`release tag=${tag}, package.json=${expected}`); } +if (lock.version !== expected || lock.packages?.[""]?.version !== expected) { + errors.push(`package-lock.json root versions must equal ${expected}`); +} +if (versions[expected] !== manifest.minAppVersion) { + errors.push(`versions.json[${expected}] must equal manifest.minAppVersion`); +} + if (errors.length > 0) { process.stderr.write(`${errors.join("\n")}\n`); process.exit(1); diff --git a/scripts/check-version.test.mjs b/scripts/check-version.test.mjs new file mode 100644 index 0000000..edb7421 --- /dev/null +++ b/scripts/check-version.test.mjs @@ -0,0 +1,111 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const script = path.resolve(import.meta.dirname, "check-version.mjs"); + +function writeFixture(overrides = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-version-")); + const version = overrides.version ?? "1.2.3"; + const minApp = overrides.minApp ?? "1.4.0"; + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "t", version })); + fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify({ + id: "t", + version: overrides.manifestVersion ?? version, + minAppVersion: minApp, + })); + fs.writeFileSync(path.join(dir, "versions.json"), JSON.stringify( + overrides.versions ?? { [version]: minApp }, + )); + fs.writeFileSync(path.join(dir, "package-lock.json"), JSON.stringify({ + name: "t", + version: overrides.lockVersion ?? version, + lockfileVersion: 3, + packages: overrides.omitLockPackageRoot + ? {} + : { "": { name: "t", version: overrides.lockPackageVersion ?? version } }, + })); + return dir; +} + +function run(dir, releaseTag) { + const env = { ...process.env }; + delete env.RELEASE_TAG; + if (releaseTag) env.RELEASE_TAG = releaseTag; + return spawnSync(process.execPath, [script], { cwd: dir, env, encoding: "utf8" }); +} + +function withFixture(overrides, fn) { + const dir = writeFixture(overrides); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("accepts fully consistent metadata", () => { + withFixture({}, (dir) => { + const result = run(dir); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Version metadata is consistent: 1\.2\.3/); + }); +}); + +test("rejects package/manifest version drift", () => { + withFixture({ manifestVersion: "1.2.2" }, (dir) => { + const result = run(dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /manifest\.json=1\.2\.2, package\.json=1\.2\.3/); + }); +}); + +test("rejects a missing versions.json entry", () => { + withFixture({ versions: { "1.0.0": "1.4.0" } }, (dir) => { + const result = run(dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /versions\.json is missing 1\.2\.3/); + }); +}); + +test("rejects a minimum-version mapping mismatch", () => { + withFixture({ versions: { "1.2.3": "1.5.0" } }, (dir) => { + const result = run(dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /versions\.json\[1\.2\.3\] must equal manifest\.minAppVersion/); + }); +}); + +test("rejects lockfile root version drift", () => { + withFixture({ lockVersion: "1.2.4" }, (dir) => { + const result = run(dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /package-lock\.json root versions must equal 1\.2\.3/); + }); +}); + +test("rejects a missing lockfile package root version", () => { + withFixture({ omitLockPackageRoot: true }, (dir) => { + const result = run(dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /package-lock\.json root versions must equal 1\.2\.3/); + }); +}); + +test("rejects a mismatched release tag", () => { + withFixture({}, (dir) => { + const result = run(dir, "wrong-tag"); + assert.equal(result.status, 1); + assert.match(result.stderr, /release tag=wrong-tag, package\.json=1\.2\.3/); + }); +}); + +test("accepts a matching release tag", () => { + withFixture({}, (dir) => { + const result = run(dir, "1.2.3"); + assert.equal(result.status, 0, result.stderr); + }); +}); From de3444dbdb7db09cdc9d8f969f5be2d2fee26c65 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:47:15 +0800 Subject: [PATCH 5/5] docs: record headless gates and native acceptance blockers --- docs/releases/1.0.0/readiness.md | 22 +++++++++---------- .../2026-09-12-1.0.0-release-readiness.md | 8 +++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/releases/1.0.0/readiness.md b/docs/releases/1.0.0/readiness.md index c7591c4..50aeac1 100644 --- a/docs/releases/1.0.0/readiness.md +++ b/docs/releases/1.0.0/readiness.md @@ -1,18 +1,18 @@ # 1.0.0 Readiness Record Release decision: NOT READY -Source commit: 2915687ebd656444054387fa474e0393bf420d7b (branch `fix/1.0-export-integrity`, local `main` clean before branching) -Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (26A428, arm64); Obsidian version not yet measured — native acceptance pending (T6) +Source commit: branch `fix/1.0-export-integrity` (implementation commits e2c9dd3, 23e670a, 3098745, 064f518 on top of merged main 207a4d3) +Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (arm64); Obsidian 1.11.5 installed; QA workspace: /var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD (temporary, retained until release verification) | Gate | Status | Source commit / artifact SHA-256 | Evidence | Remaining action | |---|---|---|---|---| | Baseline | PASS | 2915687ebd656444054387fa474e0393bf420d7b | 2026-09-15 local run: `npm run check:version` ("Version metadata is consistent: 0.7.4"), `npm run lint:obsidian-warnings`, `npm run build`, `npm test` (20 files, 360 tests passed) — all exit 0. Remote read-only: 0 open issues, 0 open PRs; release 0.7.4 (published 2026-08-26) with `main.js`, `manifest.json`, `styles.css`; latest `verify` runs green including HEAD 2915687. No vault plugin symlink present, so builds cannot update a live plugin. | T0 complete | -| Output integrity | PASS | this branch's `fix: preserve existing export documents and assets` commit | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. 2026-09-16: five export suites 145/145, full suite 378/378, lint and build exit 0. | T6 reruns the two-run case through the native export dialog | -| Outcomes | NOT RUN | Unmeasured | No run recorded | Execute T3-T4 | -| Headless artifacts | NOT RUN | Unmeasured | No run recorded | Execute T5 | -| Native artifacts | NOT RUN | Unmeasured | No run recorded | Execute T6 | -| Platforms | NOT RUN | Unmeasured | No run recorded | Execute T6 | -| Documentation | NOT RUN | Unmeasured | No review recorded | Execute T7 | -| Release gate | NOT RUN | Unmeasured | No run recorded | Execute T8 | -| Upgrade / candidate | NOT RUN | Unmeasured | No run recorded | Execute T9 | -| Published assets | NOT RUN | Unmeasured | Not published | Execute T10 | +| Output integrity | PASS | merged main 207a4d3 (PR #84, commit b44207c) | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. Five export suites 145/145, full suite green, lint and build exit 0. | T6 reruns the two-run case through the native export dialog | +| Outcomes | PASS | e2c9dd3 (T3), 23e670a (T4) | `resolveExportStatus` state table (8 rows) plus 9 integration scenarios: early cancel, mid-batch cancel, cancel after render, second-source read failure (partial), PDF renderer rejection (failed), shared-attachment retry, missing input keeps original total, report-write failure stays completed-with-warning, unresolved-link-only stays completed. Runner finalizes through one path; `onFileComplete` reports completed count minus one. `exportResultMessage` distinguishes completed/partial/cancelled/failed for the UI. Full suite 411/411, lint and build exit 0. | Native dialog feedback wording checked in T6 (A08) | +| Headless artifacts | PASS | 3098745 | Fixture generator: 520 synthetic files, all manifest SHA-256 verified; second run refused non-empty destination; PNGs decode via macOS `sips` with matching dimensions (640x240, 160x100). Contract suite 10/10: Markdown/HTML-fallback/DOCX/EPUB content cases (markers, tables, code, XML validity, relationships, spine resolution, image byte equality, no `app://`), folder batch (nested primaries, relative links, shared attachment bytes), collision A→B, export-report name protection, missing-attachment and cancellation injections. `RELEASE_ARTIFACT_DIR` persistence verified with SHA-256 index (no PDF claims). Independent `unzip -t` passed on generated DOCX and EPUB. | Native artifact acceptance in T6 | +| Native artifacts | BLOCKED | Unmeasured | Environment probed 2026-09-16: available — macOS 27.0, Obsidian 1.11.5, `/Users/Roger/my-vault` with the plugin installed as an independent directory (not a symlink). Missing — an interactive acceptance session against a fixed candidate build (A01-A12 drive real dialogs and real output inspection); Java runtime for EPUBCheck; a real DOCX reader (no Word/LibreOffice in /Applications) and a real EPUB reader. No native case has been executed; no PASS is claimed. | Run A01-A12 per protocol T6.2/T6.3 against the final candidate (install or record EPUBCheck/Java and reader gaps explicitly) | +| Platforms | BLOCKED | Unmeasured | Only the macOS row of the T6.4 matrix is executable here. Missing — Windows, Linux, iOS, Android environments and an isolated desktop Obsidian 1.4.0 install. None tested; no platform claim made. | Execute the bounded platform matrix on the required devices | +| Documentation | NOT RUN | Unmeasured | No review recorded | Execute T7 after T6 evidence (capability table and privacy wording are evidence-bound) | +| Release gate | PASS (local) | 064f518 | `scripts/check-version.mjs` extended to lockfile root and `versions.json`↔`minAppVersion` mapping; `node --test scripts/check-version.test.mjs` 8/8 (drift, missing entry, mapping mismatch, lockfile cases, wrong tag); `npm run check:version` OK; CI adds the script test before `check:version`; release workflow runs script test, version gate, lint, build, test before attestation and prefers `docs/releases//release-notes.md`; both workflow files parse as valid YAML; CLAUDE.md release guidance updated. | CI must verify the workflow changes on the PR | +| Upgrade / candidate | NOT RUN | Unmeasured | Blocked by T6: the plan requires T1-T8 including initial native artifact/platform rows before the version bump | Complete T6, then execute T9 (bump, final gates, install/upgrade smoke) | +| Published assets | NOT RUN | Unmeasured | Not published | Execute T10 after authorization | diff --git a/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md b/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md index 1dcaef2..e1d8b9e 100644 --- a/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md +++ b/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md @@ -49,12 +49,12 @@ Alternatives intentionally rejected: pre-scanning every renderer to predict exac - [x] T0 — Refresh baseline and create evidence record. - [x] T1 — Add persistent in-memory vault fixture and reproduce output corruption. - [x] T2 — Implement directory isolation, exclusive writes and report-name protection. -- [ ] T3 — Define structured outcomes and preserve partial results. -- [ ] T4 — Present accurate completion/cancellation/failure messages. -- [ ] T5 — Add reproducible artifact fixtures and automated contract checks. +- [x] T3 — Define structured outcomes and preserve partial results. +- [x] T4 — Present accurate completion/cancellation/failure messages. +- [x] T5 — Add reproducible artifact fixtures and automated contract checks. - [ ] T6 — Execute native artifact and compatibility acceptance. - [ ] T7 — Align docs, settings and metadata with verified behavior. -- [ ] T8 — Strengthen version checks and tag release verification. +- [x] T8 — Strengthen version checks and tag release verification. - [ ] T9 — Validate upgrade and the final 1.0.0 candidate. - [ ] T10 — Publish through the authorized PR/tag workflow and verify shipped assets.