diff --git a/tests/background.js b/tests/background.js index eaffe6b..00da25e 100644 --- a/tests/background.js +++ b/tests/background.js @@ -303,9 +303,11 @@ async function test(name, body) { await settle(); assert.strictEqual(absentResult, true); // Order is the assertion, not just membership: jszip and readability are - // globals the scripts after them use at load time. + // globals the scripts after them use at load time, and sanitizeHtml.js + // defines the tag lists extractHtml.js reads while it loads. assert.deepStrictEqual(absent.state.executeScripts[0].files, - ['libs/jszip.js', 'libs/readability.js', 'utils.js', 'extractHtml.js', 'saveEbook.js']); + ['libs/jszip.js', 'libs/readability.js', 'utils.js', 'sanitizeHtml.js', + 'extractHtml.js', 'saveEbook.js']); const denied = createHarness({pingResponse: undefined, executeScriptFailure: true}); let deniedResult; @@ -431,10 +433,27 @@ async function test(name, body) { assert.deepStrictEqual(request(h, {type: 'get uuid'}), [{uuid: 'second-id'}]); }); + await test('the book stylesheet persists for a book and is discarded with it', () => { + const h = createHarness(); + assert.deepStrictEqual(request(h, {type: 'get book css'}), [{css: ''}], + 'a book nobody styled has an empty stylesheet, not a missing one'); + request(h, {type: 'set book css', css: 'body{font-family:serif}'}); + assert.deepStrictEqual(request(h, {type: 'get book css'}), + [{css: 'body{font-family:serif}'}]); + request(h, {type: 'remove'}); + assert.strictEqual(h.state.local.bookCss, undefined, + 'the stylesheet belongs to the discarded chapters'); + // the per-site styles are a different thing entirely and outlive any book + request(h, {type: 'set styles', styles: [{title: 'a site'}]}); + request(h, {type: 'remove'}); + assert.deepStrictEqual(h.state.local.styles, [{title: 'a site'}]); + }); + await test('each runtime message sends at most one response', async () => { const cases = [ {type: 'get'}, {type: 'set', pages: []}, {type: 'remove'}, {type: 'get uuid'}, {type: 'get title'}, {type: 'set title', title: 'T'}, + {type: 'get book css'}, {type: 'set book css', css: 'p{}'}, {type: 'get styles'}, {type: 'set styles', styles: []}, {type: 'get current style'}, {type: 'set current style', currentStyle: 2}, {type: 'get include style'}, {type: 'set include style', includeStyle: true}, diff --git a/tests/build-edited-epub.sh b/tests/build-edited-epub.sh new file mode 100755 index 0000000..a5bdaac --- /dev/null +++ b/tests/build-edited-epub.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Builds a book out of chapters that were edited in the chapter editor, so that +# EPUBCheck sees content an edit produced. +# +# The editing happens in a real DOM - contentEditable, element removal and the +# serialization back out of it are all browser behaviour, and a shim would be +# testing the shim - so the chapters come from the headless run of +# dom/chapter-editing.html, which writes them into the page as JSON. This script +# lifts them back out of the dumped dom and hands them to the same builder the +# extension ships. +# +# The whole risk of the editing feature is emitting XHTML a reading system +# cannot parse. This is the step that would notice. +set -u + +cd "$(dirname "$0")" || exit 1 + +CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" +if [ ! -x "$CHROME" ]; then + CHROME="$(command -v google-chrome || command -v chromium || true)" +fi +if [ -z "$CHROME" ] || [ ! -x "$CHROME" ]; then + echo "SKIP: no Chrome found. Set CHROME=/path/to/chrome" >&2 + exit 0 +fi + +mkdir -p out + +dom=$("$CHROME" --headless --disable-gpu --no-sandbox \ + --allow-file-access-from-files --virtual-time-budget=8000 \ + --dump-dom "file://$PWD/dom/chapter-editing.html" 2>/dev/null) + +# One line, written by exportSavedChapters(). The dump is html, so the three +# characters the serializer escapes have to be put back before it is json again. +json=$(echo "$dom" \ + | grep -o '
.*' \ + | sed -e 's/^
//' -e 's|$||' \ + -e 's/<//g' -e 's/&/\&/g') + +if [ -z "$json" ]; then + echo "FAILED: the editing fixture produced no saved chapters" + exit 1 +fi + +printf '%s' "$json" > out/edited-chapters.json + +node build-epub.js out/edited.epub --chapters out/edited-chapters.json || exit 1 +./run-epubcheck.sh out/edited.epub diff --git a/tests/build-epub.js b/tests/build-epub.js index abe5650..8fe9fc7 100644 --- a/tests/build-epub.js +++ b/tests/build-epub.js @@ -2,13 +2,18 @@ // saveEbook.js, so what EPUBCheck validates is the file the extension ships - // not a reimplementation of it. // -// node build-epub.js [out.epub] [--single] +// node build-epub.js [out.epub] [--single] [--chapters file.json] // // --single builds a one-chapter book. That is the dominant real case ("save this // page"), and the package metadata takes different branches for it: dc:date and // dc:description come from the article itself, which would be a guess to assert // for a book assembled from several pages. // +// --chapters builds from a chapter list on disk instead of the fixture below. +// build-edited-epub.sh writes one out of the chapter editor - chapters that were +// edited in a live DOM and serialized back - so that EPUBCheck sees the content +// an edit produces and not only the content extraction produces. +// // saveEbook.js expects a browser: the handful of globals it touches are stubbed // below, and downloadBlob() is swapped for a write to disk. @@ -19,7 +24,11 @@ const vm = require('vm'); const EXT = path.join(__dirname, '..', 'web-extension'); const args = process.argv.slice(2); const single = args.indexOf('--single') > -1; -const outFile = args.filter((a) => a !== '--single')[0] || +const chaptersFlag = args.indexOf('--chapters'); +const chaptersFile = chaptersFlag > -1 ? args[chaptersFlag + 1] : null; +// the first argument that is not a flag, and not the file name belonging to one +const outFile = args.filter((a, i) => a !== '--single' && a !== '--chapters' && + !(chaptersFlag > -1 && i === chaptersFlag + 1))[0] || path.join(__dirname, 'out', 'fixture.epub'); // 1x1 png @@ -45,6 +54,16 @@ const chapters = [ }, styleFileName: 'style0.css', styleFileContent: '.a1 {color:rgb(0, 0, 0);font-size:12px;}', + // Css the user wrote for this one chapter, alongside the book-wide + // stylesheet passed to buildEbook() below. Both are here so that + // EPUBCheck sees them: user-authored css is deliberately not held to the + // allowlist extraction uses, and this is what says that a book carrying + // some is still a valid book. + customCss: '.a1 {text-align: justify;}\n' + + // and that what does escape a stylesheet is taken out before + // it can contradict a manifest that claims no remote resources + '.a1 {background-image: url("https://cdn.example.com/paper.png");}\n' + + '@import url("https://cdn.example.com/more.css");', // img-3 is the chapter a version that could not type a webp left in // storage: an image with no resolvable media type, which cannot be // declared in the manifest. It and the
text
',
+ images: [{filename: 'img-1.png', data: PNG_1X1}],
+ styleFileName: 'style0.css',
+ styleFileContent: '.sae-one{color:rgb(1, 2, 3)}'
+ }, {bookCss: 'p{margin:7px}'});
+ probe.onload = function () {
+ var inner = probe.contentDocument;
+ var image = inner.querySelector('img');
+ var paragraph = inner.querySelector('.sae-one');
+ report('the image decodes in the frame',
+ image.complete && image.naturalWidth === 1,
+ 'naturalWidth=' + image.naturalWidth);
+ report('the chapter stylesheet applies',
+ inner.defaultView.getComputedStyle(paragraph).color === 'rgb(1, 2, 3)',
+ inner.defaultView.getComputedStyle(paragraph).color);
+ report('the book stylesheet applies too',
+ inner.defaultView.getComputedStyle(paragraph).marginTop === '7px',
+ inner.defaultView.getComputedStyle(paragraph).marginTop);
+ document.title = 'DONE';
+ };
+ document.body.appendChild(probe);
+}, 300);
+
+