Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7ae375e
test(epub-codec): exercise PNG IHDR boundaries and JPEG marker-walkin…
Mearman Sep 13, 2026
4535494
test(epub-codec): assert every diagnostic error class's own name and …
Mearman Sep 13, 2026
86d7ab7
test(epub-codec): cover the scheme regex boundary and dot/empty path …
Mearman Sep 13, 2026
20b44dc
test(epub-codec): add resolveHrefTarget's first dedicated test file
Mearman Sep 13, 2026
49b008b
test(epub-codec): add buildXml's first dedicated test file
Mearman Sep 13, 2026
0b2f21d
test(epub-codec): exercise packageFromEntries' BOM and whitespace cla…
Mearman Sep 13, 2026
ba77b6d
test(epub-codec): add isXmlNode's first dedicated test file
Mearman Sep 13, 2026
755c30b
test(epub-codec): parse comment, cdata, and processing-instruction nodes
Mearman Sep 13, 2026
5d1ffb7
test(epub-codec): cover base64ToBytes' padding-position and whitespac…
Mearman Sep 13, 2026
caf151f
test(epub-codec): assert resolveOpfPath's own error messages
Mearman Sep 13, 2026
de89158
test(epub-codec): cover the remaining single/few-mutant survivors acr…
Mearman Sep 13, 2026
623da48
test(epub-codec): add footnote.ts's first dedicated test file
Mearman Sep 13, 2026
7f5fca9
test(epub-codec): assert parseOpf's error messages and its item-filte…
Mearman Sep 13, 2026
e2ba8c5
refactor(epub-codec): collapse redundant XHTML block-read branches an…
Mearman Sep 13, 2026
8801628
test(epub-codec): cover xhtml/read anchor, style-residue, and pre/tab…
Mearman Sep 13, 2026
bbfc1f1
refactor(epub-codec): drop redundant stray-content filters in xhtml/read
Mearman Sep 13, 2026
3b224ca
test(epub-codec): cover table span/caption edges and non-footnote asi…
Mearman Sep 13, 2026
e8777e7
refactor(epub-codec): drop the last redundant inert-element guard in …
Mearman Sep 14, 2026
d5cb79d
refactor(epub-codec): drop dead exhaustiveness branch and redundant b…
Mearman Sep 14, 2026
472a926
refactor(epub-codec): drop a redundant itemId-run bound in xhtml/write
Mearman Sep 14, 2026
0df2113
test(epub-codec): cover list grouping, bookmarks, and run-node edges …
Mearman Sep 14, 2026
069b2dc
test(epub-codec): cover the legacy pre heuristic and link construct e…
Mearman Sep 14, 2026
e88ea98
test(epub-codec): cover metadata trimming and dcterms:modified varian…
Mearman Sep 14, 2026
46a2a32
test(epub-codec): pin dcterms:modified matching and absent-key semant…
Mearman Sep 14, 2026
e56aec4
refactor(epub-codec): match epub:type toc via word-boundary regex in …
Mearman Sep 14, 2026
58e444f
refactor(epub-codec): test epub:type substrings without splitting int…
Mearman Sep 14, 2026
f06470d
test(epub-codec): split manifest item properties on a single whitespa…
Mearman Sep 14, 2026
862a599
refactor(epub-codec): drop a redundant empty-href guard in resolveHre…
Mearman Sep 14, 2026
12bc0c9
refactor(epub-codec): decode base64 into a plain array, not a pre-siz…
Mearman Sep 14, 2026
cd6f812
test(epub-codec): cover isXmlNode's own type-guard boundary conditions
Mearman Sep 14, 2026
2e5036c
test(epub-codec): assert writeNav3Document's own tree structurally
Mearman Sep 14, 2026
7ecab92
test(epub-codec): cover the local-file-header readers' own truncation…
Mearman Sep 14, 2026
9ab1cdd
refactor(epub-codec): stop constructing pi/declaration text fast-xml-…
Mearman Sep 14, 2026
3af6c25
test(epub-codec): cover parseNode/parseAttributes/scalarText's own sh…
Mearman Sep 14, 2026
b65db37
refactor(epub-codec): drop bounds checks the undefined-index comparis…
Mearman Sep 14, 2026
588c3cc
refactor(epub-codec): drop bounds checks looksLikeXml's own compariso…
Mearman Sep 14, 2026
3e73070
test(epub-codec): assert writeOpf's own tree structurally
Mearman Sep 14, 2026
514e97d
test(epub-codec): verify the hand-authored fixtures' own real byte co…
Mearman Sep 14, 2026
f446dfd
test(epub-codec): stop using Object.assign in isXmlNode's function-ma…
Mearman Sep 14, 2026
193dd8b
refactor(epub-codec): drop styledRun's own dead hyperlink parameter a…
Mearman Sep 14, 2026
f48a8a0
refactor(epub-codec): stop building writeEpubContent's own zip entrie…
Mearman Sep 14, 2026
2ea8416
test(epub-codec): cover readEpubContent/readEpub's own error and diag…
Mearman Sep 14, 2026
54ff703
refactor(epub-codec): replace length-bound loops with their own undef…
Mearman Sep 14, 2026
bcf4e33
refactor(epub-codec): check only the highest byte in the zip helpers'…
Mearman Sep 14, 2026
9a052ac
refactor(epub-codec): use undefined for a pi/declaration node's own i…
Mearman Sep 14, 2026
8a405ab
test(epub-codec): reject an epub:type token that merely starts with "…
Mearman Sep 14, 2026
dadda45
Merge branch 'main' into feat/100-percent-mutation-epub-codec
Mearman Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/epub-codec/src/codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ describe("EpubBytesSchema", () => {
EpubBytesSchema.safeParse(new Uint8Array([0, 1, 2, 3])).success,
).toBe(false);
});

it("rejects bytes matching only part of the zip header", () => {
// The first byte matches PK\x03\x04's own 0x50, but the rest don't -- .some() would wrongly accept this, .every() correctly rejects it.
expect(
EpubBytesSchema.safeParse(new Uint8Array([0x50, 0, 0, 0])).success,
).toBe(false);
});
});

describe("epubContentCodec", () => {
Expand Down
161 changes: 149 additions & 12 deletions packages/epub-codec/src/diagnostics-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,41 +49,79 @@ function minimalEpubEntries(
}

describe("every EpubDiagnosticCodes entry is reachable from real input", () => {
it("INVENTED_PAGE_GEOMETRY fires on every read", () => {
it("INVENTED_PAGE_GEOMETRY fires on every read, with a real, non-empty message", () => {
const { sink, codes } = collect();
const seen: EpubDiagnostic[] = [];
readEpubContent(
zipPackage(minimalEpubEntries("<html><body><p>x</p></body></html>")),
{ sink },
{
sink: (d) => {
sink(d);
seen.push(d);
},
},
);
expect(codes.has(EpubDiagnosticCodes.INVENTED_PAGE_GEOMETRY)).toBe(true);
const found = seen.find(
(d) => d.code === EpubDiagnosticCodes.INVENTED_PAGE_GEOMETRY,
);
expect(found?.message).toBe(
"EPUB has no page concept of its own; every section was given A4 + 1in default page geometry",
);
});

it("SPINE_ITEMREF_UNRESOLVED fires when a spine itemref names no manifest item", () => {
it("SPINE_ITEMREF_UNRESOLVED fires when a spine itemref names no manifest item, naming that idref in its own message", () => {
const { sink, codes } = collect();
const seen: EpubDiagnostic[] = [];
readEpubContent(
zipPackage(
minimalEpubEntries("<html><body><p>x</p></body></html>", {
opfExtra: '<itemref idref="ghost"/>',
}),
),
{ sink },
{
sink: (d) => {
sink(d);
seen.push(d);
},
},
);
expect(codes.has(EpubDiagnosticCodes.SPINE_ITEMREF_UNRESOLVED)).toBe(true);
const found = seen.find(
(d) => d.code === EpubDiagnosticCodes.SPINE_ITEMREF_UNRESOLVED,
);
expect(found?.message).toBe(
'spine itemref "ghost" names no manifest item; skipped',
);
});

it("MANIFEST_ITEM_MISSING fires when a manifest item's own part is not in the zip", () => {
it("MANIFEST_ITEM_MISSING fires when a manifest item's own part is not in the zip, naming that item's id and href in its own message", () => {
const { sink, codes } = collect();
const seen: EpubDiagnostic[] = [];
const entries = minimalEpubEntries("<html><body><p>x</p></body></html>", {
manifestExtra:
'<item id="ghost" href="ghost.xhtml" media-type="application/xhtml+xml"/>',
opfExtra: '<itemref idref="ghost"/>',
});
readEpubContent(zipPackage(entries), { sink });
readEpubContent(zipPackage(entries), {
sink: (d) => {
sink(d);
seen.push(d);
},
});
expect(codes.has(EpubDiagnosticCodes.MANIFEST_ITEM_MISSING)).toBe(true);
const found = seen.find(
(d) => d.code === EpubDiagnosticCodes.MANIFEST_ITEM_MISSING,
);
expect(found?.message).toBe(
'manifest item "ghost" names a part ("OEBPS/ghost.xhtml") the zip does not contain; skipped',
);
expect(found?.href).toBe("OEBPS/ghost.xhtml");
});

it("NAV_DOCUMENT_MISSING fires when the nav-flagged manifest item carries no toc nav", () => {
it("NAV_DOCUMENT_MISSING fires when the nav-flagged manifest item carries no toc nav, naming its own path", () => {
const { sink, codes } = collect();
const seen: EpubDiagnostic[] = [];
const encoder = new TextEncoder();
const entries = [
...minimalEpubEntries("<html><body><p>x</p></body></html>", {
Expand All @@ -97,12 +135,24 @@ describe("every EpubDiagnosticCodes entry is reachable from real input", () => {
},
] as [string, { bytes: Uint8Array<ArrayBuffer> }],
];
readEpub(zipPackage(entries), { sink });
readEpub(zipPackage(entries), {
sink: (d) => {
sink(d);
seen.push(d);
},
});
expect(codes.has(EpubDiagnosticCodes.NAV_DOCUMENT_MISSING)).toBe(true);
const found = seen.find(
(d) => d.code === EpubDiagnosticCodes.NAV_DOCUMENT_MISSING,
);
expect(found?.message).toBe(
'the nav document ("OEBPS/nav.xhtml") carries no <nav epub:type="toc">',
);
});

it("NAV_SPINE_ORDER_MISMATCH fires when the nav's own toc order disagrees with the spine", () => {
it("NAV_SPINE_ORDER_MISMATCH fires when the nav's own toc order disagrees with the spine, with a real, non-empty message", () => {
const { sink, codes } = collect();
const seen: EpubDiagnostic[] = [];
const encoder = new TextEncoder();
const entries = [
...minimalEpubEntries("<html><body><p>x</p></body></html>", {
Expand All @@ -118,22 +168,109 @@ describe("every EpubDiagnosticCodes entry is reachable from real input", () => {
},
] as [string, { bytes: Uint8Array<ArrayBuffer> }],
];
const tree = readEpub(zipPackage(entries), { sink });
const tree = readEpub(zipPackage(entries), {
sink: (d) => {
sink(d);
seen.push(d);
},
});
expect(codes.has(EpubDiagnosticCodes.NAV_SPINE_ORDER_MISMATCH)).toBe(true);
expect(tree.source?.nav).toBeDefined();
const found = seen.find(
(d) => d.code === EpubDiagnosticCodes.NAV_SPINE_ORDER_MISMATCH,
);
expect(found?.message).toBe(
"the EPUB 3 navigation document's own toc order disagrees with the spine; the spine's reading order wins and the nav document is quarantined as residue",
);
});

it("NCX_MISSING fires when the spine's toc attribute resolves to no real part", () => {
it("NCX_MISSING fires when the spine's toc attribute resolves to no real part, naming the unresolved id in its own message", () => {
const { sink, codes } = collect();
const seen: EpubDiagnostic[] = [];
readEpubContent(
zipPackage(
minimalEpubEntries("<html><body><p>x</p></body></html>", {
ncxItem: true,
}),
),
{ sink },
{
sink: (d) => {
sink(d);
seen.push(d);
},
},
);
expect(codes.has(EpubDiagnosticCodes.NCX_MISSING)).toBe(true);
const found = seen.find((d) => d.code === EpubDiagnosticCodes.NCX_MISSING);
expect(found?.message).toBe(
'the spine names an NCX ("ncx") the manifest does not resolve to a real part',
);
});

it("NCX_MISSING does not fire when the spine carries no toc attribute at all", () => {
const { sink, codes } = collect();
readEpubContent(
zipPackage(
minimalEpubEntries("<html><body><p>x</p></body></html>", {
ncxItem: false,
}),
),
{ sink },
);
expect(codes.has(EpubDiagnosticCodes.NCX_MISSING)).toBe(false);
});

it("NCX_MISSING does not fire when the toc attribute resolves to a real manifest item", () => {
const { sink, codes } = collect();
const encoder = new TextEncoder();
const ncxXml =
'<?xml version="1.0" encoding="UTF-8"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><navMap><navPoint><content src="chapter1.xhtml"/></navPoint></navMap></ncx>';
const entries = [
...minimalEpubEntries("<html><body><p>x</p></body></html>", {
ncxItem: true,
manifestExtra:
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
}),
["OEBPS/toc.ncx", { bytes: encoder.encode(ncxXml) }] as [
string,
{ bytes: Uint8Array<ArrayBuffer> },
],
];
readEpub(zipPackage(entries), { sink });
expect(codes.has(EpubDiagnosticCodes.NCX_MISSING)).toBe(false);
});

it("NAV_SPINE_ORDER_MISMATCH fires when the EPUB 2 NCX's own navMap order disagrees with the spine, with a real, non-empty message", () => {
const { sink, codes } = collect();
const seen: EpubDiagnostic[] = [];
const encoder = new TextEncoder();
const ncxXml =
'<?xml version="1.0" encoding="UTF-8"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><navMap><navPoint><content src="other.xhtml"/></navPoint></navMap></ncx>';
const entries = [
...minimalEpubEntries("<html><body><p>x</p></body></html>", {
ncxItem: true,
manifestExtra:
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
}),
["OEBPS/toc.ncx", { bytes: encoder.encode(ncxXml) }] as [
string,
{ bytes: Uint8Array<ArrayBuffer> },
],
];
const tree = readEpub(zipPackage(entries), {
sink: (d) => {
sink(d);
seen.push(d);
},
});
expect(codes.has(EpubDiagnosticCodes.NAV_SPINE_ORDER_MISMATCH)).toBe(true);
expect(tree.source?.nav).toBeDefined();
const found = seen.find(
(d) => d.code === EpubDiagnosticCodes.NAV_SPINE_ORDER_MISMATCH,
);
expect(found?.message).toBe(
"the EPUB 2 NCX's own navMap order disagrees with the spine; the spine's reading order wins and the NCX is quarantined as residue",
);
});

it("METADATA_FIELD_UNMAPPED fires for dc:publisher/dc:contributor/dc:rights", () => {
Expand Down
53 changes: 46 additions & 7 deletions packages/epub-codec/src/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
EpubInvalidMimetypeError,
EpubInvalidOpfError,
EpubPackageFlattenError,
EpubParseError,
EpubUnbalancedConstructMarkersError,
EpubUnsupportedDocumentKindError,
EpubWriteError,
NOOP_EPUB_DIAGNOSTIC_SINK,
} from "./diagnostics";

Expand All @@ -23,49 +25,86 @@ describe("NOOP_EPUB_DIAGNOSTIC_SINK", () => {
});

describe("error classes", () => {
it("EpubInvalidMimetypeError carries a stable code and default message", () => {
it("EpubParseError names itself directly, when constructed rather than through a subclass", () => {
const error = new EpubParseError("epub/example", "an example message");
expect(error.name).toBe("EpubParseError");
expect(error.code).toBe("epub/example");
expect(error.message).toBe("an example message");
});

it("EpubWriteError names itself directly, when constructed rather than through a subclass", () => {
const error = new EpubWriteError("epub/example", "an example message");
expect(error.name).toBe("EpubWriteError");
expect(error.code).toBe("epub/example");
expect(error.message).toBe("an example message");
});

it("EpubInvalidMimetypeError carries a stable code, name, and default message", () => {
const error = new EpubInvalidMimetypeError();
expect(error.code).toBe("epub/invalid-mimetype");
expect(error.name).toBe("EpubInvalidMimetypeError");
expect(error.message).toBe(
'the zip\'s first entry is not a stored "mimetype" entry containing exactly "application/epub+zip"',
);
expect(error).toBeInstanceOf(Error);
});

it("EpubInvalidContainerError carries a stable code", () => {
expect(new EpubInvalidContainerError().code).toBe("epub/invalid-container");
it("EpubInvalidContainerError carries a stable code, name, and default message", () => {
const error = new EpubInvalidContainerError();
expect(error.code).toBe("epub/invalid-container");
expect(error.name).toBe("EpubInvalidContainerError");
expect(error.message).toBe(
"META-INF/container.xml is missing or names no OPF rootfile",
);
});

it("EpubInvalidOpfError carries a stable code and a caller message", () => {
it("EpubInvalidOpfError carries a stable code, name, and a caller message", () => {
const error = new EpubInvalidOpfError("no <package> root element");
expect(error.code).toBe("epub/invalid-opf");
expect(error.name).toBe("EpubInvalidOpfError");
expect(error.message).toBe("no <package> root element");
});

it("EpubEmptySpineError carries a stable code", () => {
expect(new EpubEmptySpineError().code).toBe("epub/empty-spine");
it("EpubEmptySpineError carries a stable code, name, and default message", () => {
const error = new EpubEmptySpineError();
expect(error.code).toBe("epub/empty-spine");
expect(error.name).toBe("EpubEmptySpineError");
expect(error.message).toBe(
"the spine names no resolvable, readable content",
);
});

it("EpubUnsupportedDocumentKindError names the offending kind", () => {
const error = new EpubUnsupportedDocumentKindError("spreadsheet");
expect(error.code).toBe("epub/write-side-not-wordprocessing");
expect(error.name).toBe("EpubUnsupportedDocumentKindError");
expect(error.kind).toBe("spreadsheet");
expect(error.message).toBe(
"writeEpubContent only supports a 'wordprocessing' ContentDocument, got 'spreadsheet'",
);
});

it("EpubUnbalancedConstructMarkersError describes an unmatchedEnd", () => {
const error = new EpubUnbalancedConstructMarkersError("unmatchedEnd", 3);
expect(error.code).toBe("epub/unbalanced-construct-markers");
expect(error.name).toBe("EpubUnbalancedConstructMarkersError");
expect(error.imbalanceKind).toBe("unmatchedEnd");
expect(error.blockIndex).toBe(3);
expect(error.message).toContain(
"a constructEnd marker closes no open construct",
);
expect(error.message).toContain("index 3");
});

it("EpubUnbalancedConstructMarkersError describes an unclosedStart", () => {
const error = new EpubUnbalancedConstructMarkersError("unclosedStart", 0);
expect(error.message).toContain("never closed");
expect(error.message).toContain("a constructStart marker is never closed");
});

it("EpubPackageFlattenError wraps a thrown cause's message", () => {
const error = new EpubPackageFlattenError(new Error("no such style ref"));
expect(error.code).toBe("epub/package-flatten-failed");
expect(error.name).toBe("EpubPackageFlattenError");
expect(error.message).toContain("no such style ref");
});

Expand Down
Loading
Loading