diff --git a/examples/twenty/index.html b/examples/twenty/index.html index 6dab7cf..03f788f 100644 --- a/examples/twenty/index.html +++ b/examples/twenty/index.html @@ -22,7 +22,7 @@ var bookCacheUrl = new URL("appcache.html", window.location.href); var store = new LocalStorageStore.default(webpubManifestUrl.href); - var cacher = new ServiceWorkerCacher.default(store, webpubManifestUrl, "../../sw.js", bookCacheUrl); + var cacher = new ServiceWorkerCacher.default(store, webpubManifestUrl, "../sw.js", bookCacheUrl); var paginator = new ColumnsPaginatedBookView.default(); var scroller = new ScrollingBookView.default(); diff --git a/src/ApplicationCacheCacher.ts b/src/ApplicationCacheCacher.ts index 41def20..3cee0e9 100644 --- a/src/ApplicationCacheCacher.ts +++ b/src/ApplicationCacheCacher.ts @@ -19,6 +19,7 @@ export default class ApplicationCacheCacher implements Cacher { private readonly bookCacheUrl: URL; protected bookCacheElement: HTMLIFrameElement; private statusUpdateCallback: (status: CacheStatus) => void = () => {}; + private status: CacheStatus = CacheStatus.Uncached; public constructor(bookCacheUrl: URL) { this.bookCacheUrl = bookCacheUrl; @@ -32,16 +33,20 @@ export default class ApplicationCacheCacher implements Cacher { this.updateStatus(); this.bookCacheElement.addEventListener("load", () => { - this.updateStatus(); + try { + this.updateStatus(); - const bookCache = this.bookCacheElement.contentWindow.applicationCache; + const bookCache = this.bookCacheElement.contentWindow.applicationCache; - bookCache.oncached = this.updateStatus.bind(this); - bookCache.onchecking = this.updateStatus.bind(this); - bookCache.ondownloading = this.updateStatus.bind(this); - bookCache.onerror = this.handleError.bind(this); - bookCache.onnoupdate = this.updateStatus.bind(this); - bookCache.onupdateready = this.updateStatus.bind(this); + bookCache.oncached = this.updateStatus.bind(this); + bookCache.onchecking = this.updateStatus.bind(this); + bookCache.ondownloading = this.updateStatus.bind(this); + bookCache.onerror = this.handleError.bind(this); + bookCache.onnoupdate = this.updateStatus.bind(this); + bookCache.onupdateready = this.updateStatus.bind(this); + } catch (err) { + this.handleError(); + } }); return new Promise(resolve => resolve()); @@ -52,6 +57,10 @@ export default class ApplicationCacheCacher implements Cacher { this.updateStatus(); } + public getStatus(): CacheStatus { + return this.status; + } + protected updateStatus() { let status: CacheStatus; let appCacheStatus = window.applicationCache.UNCACHED; @@ -77,10 +86,12 @@ export default class ApplicationCacheCacher implements Cacher { status = CacheStatus.Downloaded; } + this.status = status; this.statusUpdateCallback(status); } protected handleError() { + this.status = CacheStatus.Error; this.statusUpdateCallback(CacheStatus.Error); } } \ No newline at end of file diff --git a/src/BookSettings.ts b/src/BookSettings.ts index d65706f..0717ab3 100644 --- a/src/BookSettings.ts +++ b/src/BookSettings.ts @@ -21,7 +21,6 @@ const optionTemplate = (liClassName: string, buttonClassName: string, label: str const offlineTemplate = `
  • -
  • `; @@ -37,12 +36,6 @@ const increaseSvg = ` void) { - this.offlineEnabledCallback = callback; - } - private setupEvents(): void { for (const view of this.bookViews) { const button = this.viewButtons[view.name]; @@ -222,14 +198,6 @@ export default class BookSettings { event.preventDefault(); }); } - - this.offlineButton.addEventListener("click", (event: MouseEvent) => { - this.offlineStatus = OfflineStatus.Enabled; - this.offlineEnabledCallback(); - this.updateOfflineButton(); - this.storeOfflineEnabled(true); - event.preventDefault(); - }); } private updateViewButtons(): void { @@ -260,14 +228,6 @@ export default class BookSettings { this.fontSizeLabel.style.fontSize = this.selectedFontSize; } - private updateOfflineButton(): void { - if (this.getOfflineStatus() === OfflineStatus.Enabled) { - this.offlineButton.style.display = "none"; - } else { - this.offlineButton.style.display = "block"; - } - } - public getSelectedView(): BookView { return this.selectedView; } @@ -276,22 +236,6 @@ export default class BookSettings { return this.selectedFontSize; } - public getOfflineStatus(): OfflineStatus { - return this.offlineStatus; - } - - public async askUserToEnableOfflineUse(): Promise { - const enable = window.confirm("Would you like to download this book to read offline?"); - if (enable) { - this.offlineStatus = OfflineStatus.Enabled; - this.offlineEnabledCallback(); - } else { - this.offlineStatus = OfflineStatus.Disabled; - } - this.updateOfflineButton(); - await this.storeOfflineEnabled(enable); - } - public getOfflineStatusElement(): HTMLElement { return this.offlineStatusElement; } @@ -303,8 +247,4 @@ export default class BookSettings { private async storeSelectedFontSize(fontSize: string): Promise { return this.store.set(BookSettings.SELECTED_FONT_SIZE_KEY, fontSize); } - - private async storeOfflineEnabled(offlineEnabled: boolean): Promise { - return this.store.set(BookSettings.OFFLINE_ENABLED_KEY, offlineEnabled ? "true" : "false"); - } }; diff --git a/src/Cacher.ts b/src/Cacher.ts index 43996c4..925cd90 100644 --- a/src/Cacher.ts +++ b/src/Cacher.ts @@ -25,6 +25,9 @@ interface Cacher { // Register a function to call when the cache status changes. onStatusUpdate(callback: (status: CacheStatus) => void): void; + + // Return the current CacheStatus. + getStatus(): CacheStatus; } export default Cacher; \ No newline at end of file diff --git a/src/IFrameNavigator.ts b/src/IFrameNavigator.ts index 273259f..6a5bf79 100644 --- a/src/IFrameNavigator.ts +++ b/src/IFrameNavigator.ts @@ -8,7 +8,6 @@ import Annotator from "./Annotator"; import Manifest from "./Manifest"; import { Link } from "./Manifest"; import BookSettings from "./BookSettings"; -import { OfflineStatus } from "./BookSettings"; import EventHandler from "./EventHandler"; import * as HTMLUtilities from "./HTMLUtilities"; @@ -78,6 +77,10 @@ const template = `
    +
    @@ -117,6 +120,8 @@ export default class IFrameNavigator implements Navigator { private tocView: HTMLDivElement; private settingsView: HTMLDivElement; private loadingMessage: HTMLDivElement; + private errorMessage: HTMLDivElement; + private tryAgainButton: HTMLButtonElement; private infoTop: HTMLDivElement; private infoBottom: HTMLDivElement; private bookTitle: HTMLSpanElement; @@ -189,6 +194,8 @@ export default class IFrameNavigator implements Navigator { this.tocView = HTMLUtilities.findRequiredElement(element, ".contents-view") as HTMLDivElement; this.settingsView = HTMLUtilities.findRequiredElement(element, ".settings-view") as HTMLDivElement; this.loadingMessage = HTMLUtilities.findRequiredElement(element, "div[class=loading]") as HTMLDivElement; + this.errorMessage = HTMLUtilities.findRequiredElement(element, "div[class=error]") as HTMLDivElement; + this.tryAgainButton = HTMLUtilities.findRequiredElement(element, "button[class=try-again]") as HTMLButtonElement; this.infoTop = HTMLUtilities.findRequiredElement(element, "div[class='info top']") as HTMLDivElement; this.infoBottom = HTMLUtilities.findRequiredElement(element, "div[class='info bottom']") as HTMLDivElement; this.bookTitle = HTMLUtilities.findRequiredElement(this.infoTop, "span[class=book-title]") as HTMLSpanElement; @@ -209,11 +216,8 @@ export default class IFrameNavigator implements Navigator { this.settings.renderControls(this.settingsView); this.settings.onViewChange(this.updateBookView.bind(this)); this.settings.onFontSizeChange(this.updateFontSize.bind(this)); - this.settings.onOfflineEnabled(this.enableOffline.bind(this)); this.cacher.onStatusUpdate(this.updateOfflineCacheStatus.bind(this)); - if (this.settings.getOfflineStatus() === OfflineStatus.Enabled) { - this.enableOffline(); - } + this.enableOffline(); return await this.loadManifest(); } catch (err) { @@ -237,6 +241,8 @@ export default class IFrameNavigator implements Navigator { this.settingsControl.addEventListener("click", this.handleSettingsClick.bind(this)); this.settingsView.addEventListener("click", this.hideSettings.bind(this)); + + this.tryAgainButton.addEventListener("click", this.tryAgain.bind(this)); } private updateBookView(): void { @@ -300,7 +306,9 @@ export default class IFrameNavigator implements Navigator { } private enableOffline(): void { - this.cacher.enable(); + if (this.cacher.getStatus() !== CacheStatus.Downloaded) { + this.cacher.enable(); + } } private updateOfflineCacheStatus(status: CacheStatus): void { @@ -341,7 +349,7 @@ export default class IFrameNavigator implements Navigator { href = new URL(link.href, this.manifestUrl.href).href; } linkElement.href = href; - linkElement.text = link.title || ""; + linkElement.innerHTML = link.title || ""; linkElement.addEventListener("click", (event: Event) => { event.preventDefault(); event.stopPropagation(); @@ -398,97 +406,103 @@ export default class IFrameNavigator implements Navigator { } private async handleIFrameLoad(): Promise { + this.errorMessage.style.display = "none"; this.showLoadingMessageAfterDelay(); - if (!this.firstLoad) { - this.hideTOC(); - } - this.firstLoad = false; - - let bookViewPosition = 0; - if (this.newPosition) { - bookViewPosition = this.newPosition.position; - this.newPosition = null; - } - this.updateBookView(); - this.updateFontSize(); - this.settings.getSelectedView().start(bookViewPosition); + try { + if (!this.firstLoad) { + this.hideTOC(); + } + this.firstLoad = false; - if (this.newElementId) { - this.settings.getSelectedView().goToElement(this.newElementId); - this.newElementId = null; - } + let bookViewPosition = 0; + if (this.newPosition) { + bookViewPosition = this.newPosition.position; + this.newPosition = null; + } + this.updateBookView(); + this.updateFontSize(); + this.settings.getSelectedView().start(bookViewPosition); - let currentLocation = this.iframe.src; - if (this.iframe.contentDocument && this.iframe.contentDocument.location && this.iframe.contentDocument.location.href) { - currentLocation = this.iframe.contentDocument.location.href; - } + if (this.newElementId) { + this.settings.getSelectedView().goToElement(this.newElementId); + this.newElementId = null; + } - if (currentLocation.indexOf("#") !== -1) { - // Letting the iframe load the anchor itself doesn't always work. - // For example, with CSS column-based pagination, you can end up - // between two columns, and we can't reset the position in some - // browsers. Instead, we grab the element id and reload the iframe - // without it, then let the view figure out how to go to that element - // on the next load event. - - const elementId = currentLocation.slice(currentLocation.indexOf("#") + 1); - // Set the element to go to the next time the iframe loads. - this.newElementId = elementId; - // Reload the iframe without the anchor. - this.iframe.src = currentLocation.slice(0, currentLocation.indexOf("#")); - return new Promise(resolve => resolve()); - } + let currentLocation = this.iframe.src; + if (this.iframe.contentDocument && this.iframe.contentDocument.location && this.iframe.contentDocument.location.href) { + currentLocation = this.iframe.contentDocument.location.href; + } - this.updatePositionInfo(); + if (currentLocation.indexOf("#") !== -1) { + // Letting the iframe load the anchor itself doesn't always work. + // For example, with CSS column-based pagination, you can end up + // between two columns, and we can't reset the position in some + // browsers. Instead, we grab the element id and reload the iframe + // without it, then let the view figure out how to go to that element + // on the next load event. + + const elementId = currentLocation.slice(currentLocation.indexOf("#") + 1); + // Set the element to go to the next time the iframe loads. + this.newElementId = elementId; + // Reload the iframe without the anchor. + this.iframe.src = currentLocation.slice(0, currentLocation.indexOf("#")); + return new Promise(resolve => resolve()); + } - const manifest = await Manifest.getManifest(this.manifestUrl, this.store); - const previous = manifest.getPreviousSpineItem(currentLocation); - if (previous && previous.href) { - this.previousChapterLink.href = new URL(previous.href, this.manifestUrl.href).href; - this.previousChapterLink.className = ""; - } else { - this.previousChapterLink.removeAttribute("href"); - this.previousChapterLink.className = "disabled"; - } + this.updatePositionInfo(); - const next = manifest.getNextSpineItem(currentLocation); - if (next && next.href) { - this.nextChapterLink.href = new URL(next.href, this.manifestUrl.href).href; - this.nextChapterLink.className = ""; - } else { - this.nextChapterLink.removeAttribute("href"); - this.nextChapterLink.className = "disabled"; - } + const manifest = await Manifest.getManifest(this.manifestUrl, this.store); + const previous = manifest.getPreviousSpineItem(currentLocation); + if (previous && previous.href) { + this.previousChapterLink.href = new URL(previous.href, this.manifestUrl.href).href; + this.previousChapterLink.className = ""; + } else { + this.previousChapterLink.removeAttribute("href"); + this.previousChapterLink.className = "disabled"; + } - this.setActiveTOCItem(currentLocation); + const next = manifest.getNextSpineItem(currentLocation); + if (next && next.href) { + this.nextChapterLink.href = new URL(next.href, this.manifestUrl.href).href; + this.nextChapterLink.className = ""; + } else { + this.nextChapterLink.removeAttribute("href"); + this.nextChapterLink.className = "disabled"; + } - if (manifest.metadata.title) { - this.bookTitle.innerHTML = manifest.metadata.title; - } + this.setActiveTOCItem(currentLocation); - const spineItem = manifest.getSpineItem(currentLocation); - if (spineItem !== null) { - if (spineItem.title) { - this.chapterTitle.innerHTML = "(" + spineItem.title + ")"; - } else { - this.chapterTitle.innerHTML = "(Chapter)"; + if (manifest.metadata.title) { + this.bookTitle.innerHTML = manifest.metadata.title; } - } - if (this.eventHandler) { - this.eventHandler.setupEvents(this.iframe.contentDocument); - } + const spineItem = manifest.getSpineItem(currentLocation); + if (spineItem !== null) { + if (spineItem.title) { + this.chapterTitle.innerHTML = "(" + spineItem.title + ")"; + } else { + this.chapterTitle.innerHTML = "(Chapter)"; + } + } - if (this.annotator) { - await this.saveCurrentReadingPosition(); - } - this.hideLoadingMessage(); + if (this.eventHandler) { + this.eventHandler.setupEvents(this.iframe.contentDocument); + } - if (this.settings.getOfflineStatus() === OfflineStatus.NoSelection) { - setTimeout(this.settings.askUserToEnableOfflineUse.bind(this.settings), 0); + if (this.annotator) { + await this.saveCurrentReadingPosition(); + } + this.hideLoadingMessage(); + return new Promise(resolve => resolve()); + } catch (err) { + this.errorMessage.style.display = "block"; + return new Promise((_, reject) => reject()); } + } - return new Promise(resolve => resolve()); + private tryAgain() { + this.iframe.src = this.iframe.src; + this.enableOffline(); } private isDisplayed(element: HTMLDivElement | HTMLUListElement) { diff --git a/src/ServiceWorkerCacher.ts b/src/ServiceWorkerCacher.ts index 210d814..91bdde6 100644 --- a/src/ServiceWorkerCacher.ts +++ b/src/ServiceWorkerCacher.ts @@ -12,7 +12,7 @@ export default class ServiceWorkerCacher implements Cacher { private readonly manifestUrl: URL; private readonly areServiceWorkersSupported: boolean; private readonly fallbackCacher: ApplicationCacheCacher | null; - private cacheStatus: CacheStatus = CacheStatus.Uncached; + protected cacheStatus: CacheStatus = CacheStatus.Uncached; private statusUpdateCallback: (status: CacheStatus) => void = () => {}; /** Create a ServiceWorkerCacher. */ @@ -36,7 +36,7 @@ export default class ServiceWorkerCacher implements Cacher { if (this.fallbackCacher) { return this.fallbackCacher.enable(); - } else if (this.areServiceWorkersSupported) { + } else if (this.areServiceWorkersSupported && (this.cacheStatus !== CacheStatus.Downloaded)) { this.cacheStatus = CacheStatus.Downloading; this.updateStatus(); navigator.serviceWorker.register(this.serviceWorkerPath); @@ -57,20 +57,15 @@ export default class ServiceWorkerCacher implements Cacher { private async verifyAndCacheManifest(manifestUrl: URL): Promise { await navigator.serviceWorker.ready; try { - const cache = await window.caches.open(manifestUrl.href); - const response = await cache.match(manifestUrl.href); - // If the manifest wasn't already cached, we need to cache everything. - if (!response) { - // Invoke promises concurrently... - const promises = [this.cacheManifest(manifestUrl), this.cacheUrls(["index.html", manifestUrl.href], manifestUrl)]; - // then wait for all of them to resolve. - for (const promise of promises) { - await promise; - } + // Invoke promises concurrently... + const promises = [this.cacheManifest(manifestUrl), this.cacheUrls(["index.html", manifestUrl.href], manifestUrl)]; + // then wait for all of them to resolve. + for (const promise of promises) { + await promise; } return new Promise(resolve => resolve()); } catch (err) { - return new Promise((_, reject) => reject()); + return new Promise((_, reject) => reject(err)); } } @@ -117,6 +112,10 @@ export default class ServiceWorkerCacher implements Cacher { } } + public getStatus(): CacheStatus { + return this.cacheStatus; + } + private updateStatus(): void { this.statusUpdateCallback(this.cacheStatus); } diff --git a/src/styles/sass/_colors.scss b/src/styles/sass/_colors.scss index b8f95f8..bd93703 100644 --- a/src/styles/sass/_colors.scss +++ b/src/styles/sass/_colors.scss @@ -22,7 +22,7 @@ $mta-white: #fff; $mta-black: #111; // NYPL Reds -$nypl-red: #d0343a; +$mta-error-red: #d0343a; $nypl-red-dark: #97272c; diff --git a/src/styles/sass/main.scss b/src/styles/sass/main.scss index d1b95d2..d986d2d 100644 --- a/src/styles/sass/main.scss +++ b/src/styles/sass/main.scss @@ -264,6 +264,35 @@ a { width: 12.5rem; } +.error { + background-color: transparentize($mta-white, 0.125); + //border: 2px solid $mta-error-red; + color: $mta-error-red; + //font-family: $sans-serif-stack; + height: 100%; + //left: calc(50% - 6.25rem); + padding-top: 40vh; + position: fixed; + text-align: center; + //top: calc(50% - 6.25rem); + width: 100%; + + span { + display: block; + margin-bottom: 0.75rem; + } + + button { + color: $mta-error-red; + border: 0.125rem solid $mta-error-red; + border-radius: 0.3125rem; + //box-shadow: 0 0 17rem 3.75rem transparentize($mta-dark-gray, 0.9); + font-size: 1rem; + font-weight: 700; + padding: 1.4rem; + } +} + .controls-view { font-family: $sans-serif-stack; font-size: $basefont; diff --git a/src/sw.ts b/src/sw.ts index 75f3cf2..b91bbe0 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -21,8 +21,17 @@ self.addEventListener('activate', () => { }); self.addEventListener('fetch', event => { - const cachedOrFetchedResponse = self.caches.match((event as any).request).then(response => { - return response || self.fetch((event as any).request); + // Response from the cache immediately if possible, but also fetch an update. + + const cachedOrFetchedResponse = self.caches.open(CACHE_NAME).then((cache: any) => { + return self.caches.match((event as any).request).then(cacheResponse => { + const fetchPromise: Promise = self.fetch((event as any).request).then((fetchResponse) => { + cache.put((event as any).request, fetchResponse.clone()); + return fetchResponse; + }); + return cacheResponse || fetchPromise; + }); }); + (event as any).respondWith(cachedOrFetchedResponse); }); diff --git a/test/ApplicationCacheCacherTests.ts b/test/ApplicationCacheCacherTests.ts index c2e1bd0..4a9b2b4 100644 --- a/test/ApplicationCacheCacherTests.ts +++ b/test/ApplicationCacheCacherTests.ts @@ -118,4 +118,52 @@ describe("ApplicationCacheCacher", () => { expect(callback.args[5][0]).to.equal(CacheStatus.Error); }); }); + + describe("#getStatus", () => { + it("should provide current application cache status", async () => { + const iframe = document.createElement("iframe"); + // The element must be in a document for iframe load events to work. + jsdomWindow.document.body.appendChild(iframe); + + iframe.src = "http://example.com/test"; + + // Cacher with a mock implementation of enable, which + // is tested separately, and a method to simulate an error. + class MockCacher extends ApplicationCacheCacher { + public enable() { + this.bookCacheElement = iframe; + this.updateStatus(); + return new Promise(resolve => resolve()); + } + + public error() { + this.handleError(); + } + } + + const cacher: MockCacher = new MockCacher(new URL("http://example.com/fallback.html")); + expect(cacher.getStatus()).to.equal(CacheStatus.Uncached); + + (iframe.contentWindow as any).applicationCache = {}; + + (iframe.contentWindow as any).applicationCache.status = window.applicationCache.UPDATEREADY; + await cacher.enable(); + expect(cacher.getStatus()).to.equal(CacheStatus.UpdateAvailable); + + (iframe.contentWindow as any).applicationCache.status = window.applicationCache.DOWNLOADING; + await cacher.enable(); + expect(cacher.getStatus()).to.equal(CacheStatus.Downloading); + + (iframe.contentWindow as any).applicationCache.status = window.applicationCache.OBSOLETE; + await cacher.enable(); + expect(cacher.getStatus()).to.equal(CacheStatus.Uncached); + + (iframe.contentWindow as any).applicationCache.status = window.applicationCache.IDLE; + await cacher.enable(); + expect(cacher.getStatus()).to.equal(CacheStatus.Downloaded); + + cacher.error(); + expect(cacher.getStatus()).to.equal(CacheStatus.Error); + }); + }); }); \ No newline at end of file diff --git a/test/BookSettingsTest.ts b/test/BookSettingsTest.ts index b78c309..83b89ee 100644 --- a/test/BookSettingsTest.ts +++ b/test/BookSettingsTest.ts @@ -2,7 +2,6 @@ import { expect } from "chai"; import { stub } from "sinon"; import BookSettings from "../src/BookSettings"; -import { OfflineStatus } from "../src/BookSettings"; import BookView from "../src/BookView"; import MemoryStore from "../src/MemoryStore"; @@ -109,20 +108,6 @@ describe("BookSettings", () => { settings = await BookSettings.create(store, [view1, view2], [10, 12, 14, 16]); expect(settings.getSelectedFontSize()).to.equal("14px"); }); - - it("obtains the selected offline setting from the store", async () => { - await store.set("settings-offline-enabled", "true"); - settings = await BookSettings.create(store, [view1], [12]); - expect(settings.getOfflineStatus()).to.equal(OfflineStatus.Enabled); - - await store.set("settings-offline-enabled", "false"); - settings = await BookSettings.create(store, [view1], [12]); - expect(settings.getOfflineStatus()).to.equal(OfflineStatus.Disabled); - }); - - it("has no offline selection if there isn't one in the store", async () => { - expect(settings.getOfflineStatus()).to.equal(OfflineStatus.NoSelection); - }); }); describe("#renderControls", () => { @@ -287,32 +272,11 @@ describe("BookSettings", () => { expect(label.style.fontSize).to.equal("16px"); }); - it("renders offline button and status", async () => { + it("renders offline status", async () => { const element = document.createElement("div"); settings.renderControls(element); - let offlineButton = element.querySelector("button[class='enable-offline']") as HTMLButtonElement; - expect(offlineButton.innerHTML).to.contain("offline"); - expect(offlineButton.style.display).not.to.equal("none"); - - let offlineStatus = element.querySelector("div[class='offline-status']") as HTMLDivElement; - expect(offlineStatus).not.to.be.null; - - click(offlineButton); - await pause(); - expect(settings.getOfflineStatus()).to.equal(OfflineStatus.Enabled); - - const storedOfflineEnabled = await store.get("settings-offline-enabled"); - expect(storedOfflineEnabled).to.equal("true"); - - store.set("settings-offline-enabled", "true"); - settings = await BookSettings.create(store, [view1], [12]); - settings.renderControls(element); - - offlineButton = element.querySelector("button[class='enable-offline']") as HTMLButtonElement; - expect(offlineButton.style.display).to.equal("none"); - - offlineStatus = element.querySelector("div[class='offline-status']") as HTMLDivElement; + const offlineStatus = element.querySelector("div[class='offline-status']") as HTMLDivElement; expect(offlineStatus).not.to.be.null; }); @@ -378,46 +342,4 @@ describe("BookSettings", () => { expect(fontSizeChanged.callCount).to.equal(2); }); }); - - describe("#onOfflineEnabled", () => { - it("sets up offline enabled callback", async () => { - const element = document.createElement("div"); - settings.renderControls(element); - - const offlineEnabled = stub(); - settings.onOfflineEnabled(offlineEnabled); - - const offlineButton = element.querySelector("button[class='enable-offline']") as HTMLButtonElement; - click(offlineButton); - await pause(); - expect(offlineEnabled.callCount).to.equal(1); - }); - }); - - describe("#askUserToEnableOfflineUse", () => { - it("sets offline status based on user's response", async () => { - const offlineEnabled = stub(); - settings.onOfflineEnabled(offlineEnabled); - - const element = document.createElement("div"); - settings.renderControls(element); - - const confirmStub = stub(window, "confirm").returns(true); - await settings.askUserToEnableOfflineUse(); - expect(settings.getOfflineStatus()).to.equal(OfflineStatus.Enabled); - let storedSetting = await store.get("settings-offline-enabled"); - expect(storedSetting).to.equal("true"); - let offlineButton = element.querySelector("button[class='enable-offline']") as HTMLButtonElement; - expect(offlineButton.style.display).to.equal("none"); - expect(offlineEnabled.callCount).to.equal(1); - - confirmStub.returns(false); - await settings.askUserToEnableOfflineUse(); - expect(settings.getOfflineStatus()).to.equal(OfflineStatus.Disabled); - storedSetting = await store.get("settings-offline-enabled"); - expect(storedSetting).to.equal("false"); - expect(offlineButton.style.display).not.to.equal("none"); - expect(offlineEnabled.callCount).to.equal(1); - }); - }); }); \ No newline at end of file diff --git a/test/IFrameNavigatorTests.ts b/test/IFrameNavigatorTests.ts index 82fb93b..781c8b3 100644 --- a/test/IFrameNavigatorTests.ts +++ b/test/IFrameNavigatorTests.ts @@ -11,7 +11,6 @@ import ScrollingBookView from "../src/ScrollingBookView"; import Annotator from "../src/Annotator"; import Manifest from "../src/Manifest"; import BookSettings from "../src/BookSettings"; -import { OfflineStatus } from "../src/BookSettings"; import MemoryStore from "../src/MemoryStore"; import EventHandler from "../src/EventHandler"; @@ -20,6 +19,7 @@ describe("IFrameNavigator", () => { let enable: Sinon.SinonStub; let onStatusUpdate: Sinon.SinonStub; + let getStatus: Sinon.SinonStub; let cacher: Cacher; let paginatorStart: Sinon.SinonStub; @@ -44,12 +44,9 @@ describe("IFrameNavigator", () => { let renderControls: Sinon.SinonStub; let onViewChange: Sinon.SinonStub; let onFontSizeChange: Sinon.SinonStub; - let onOfflineEnabled: Sinon.SinonStub; let getSelectedView: Sinon.SinonStub; let getSelectedFontSize: Sinon.SinonStub; - let getOfflineStatus: Sinon.SinonStub; let getOfflineStatusElement: Sinon.SinonStub; - let askUserToEnableOfflineUse: Sinon.SinonStub; let settings: BookSettings; let setupEvents: Sinon.SinonStub; @@ -65,6 +62,10 @@ describe("IFrameNavigator", () => { public onStatusUpdate(callback: (status: CacheStatus) => void) { return onStatusUpdate(callback); } + + public getStatus(): CacheStatus { + return getStatus(); + } } class MockPaginator implements PaginatedBookView { @@ -142,24 +143,15 @@ describe("IFrameNavigator", () => { public onFontSizeChange(callback: () => void) { onFontSizeChange(callback); } - public onOfflineEnabled(callback: () => void) { - onOfflineEnabled(callback); - } public getSelectedView() { return getSelectedView(); } public getSelectedFontSize() { return getSelectedFontSize(); } - public getOfflineStatus() { - return getOfflineStatus(); - } public getOfflineStatusElement() { return getOfflineStatusElement(); } - public askUserToEnableOfflineUse() { - return askUserToEnableOfflineUse(); - } } class MockEventHandler extends EventHandler { @@ -205,6 +197,7 @@ describe("IFrameNavigator", () => { store.set("manifest", JSON.stringify(manifest)); enable = stub(); onStatusUpdate = stub(); + getStatus = stub(); cacher = new MockCacher(); paginatorStart = stub(); @@ -229,12 +222,9 @@ describe("IFrameNavigator", () => { renderControls = stub(); onViewChange = stub(); onFontSizeChange = stub(); - onOfflineEnabled = stub(); getSelectedView = stub().returns(paginator); getSelectedFontSize = stub().returns("14px"); - getOfflineStatus = stub().returns(OfflineStatus.Disabled); getOfflineStatusElement = stub().returns(offlineStatusElement); - askUserToEnableOfflineUse = stub(); settings = await MockSettings.create(store, [paginator, scroller], [14, 16]); setupEvents = stub(); @@ -402,16 +392,6 @@ describe("IFrameNavigator", () => { expect(paginator.sideMargin).to.equal(32); }); - it("should give the settings a function to call when offline is enabled", () => { - expect(onOfflineEnabled.callCount).to.equal(1); - expect(enable.callCount).to.equal(0); - - const enableOffline = onOfflineEnabled.args[0][0]; - enableOffline(); - - expect(enable.callCount).to.equal(1); - }); - it("should render the cache status", () => { expect(onStatusUpdate.callCount).to.equal(1); const callback = onStatusUpdate.args[0][0]; @@ -435,23 +415,10 @@ describe("IFrameNavigator", () => { expect(offlineStatusElement.innerHTML).to.contain("Error"); }); - it("should enable the cacher if offline is enabled in the settings", async () => { - expect(enable.callCount).to.equal(0); - - getOfflineStatus.returns(OfflineStatus.Enabled); - await IFrameNavigator.create(element, new URL("http://example.com/manifest.json"), store, cacher, settings, annotator, paginator, scroller); + it("should enable the cacher on load", async () => { expect(enable.callCount).to.equal(1); }); - it("should ask the user to enable offline use if there's no selection", async () => { - expect(askUserToEnableOfflineUse.callCount).to.equal(0); - - getOfflineStatus.returns(OfflineStatus.NoSelection); - await IFrameNavigator.create(element, new URL("http://example.com/manifest.json"), store, cacher, settings, annotator, paginator, scroller); - await pause(10); - expect(askUserToEnableOfflineUse.callCount).to.equal(1); - }); - it("should start the selected book view", async () => { await pause(); expect(paginatorStart.callCount).to.equal(1); @@ -875,6 +842,32 @@ describe("IFrameNavigator", () => { await pause(100); expect(loading.style.display).to.equal("none"); }); + + it("should show error message when iframe fails to load", async () => { + const iframe = element.querySelector("iframe") as HTMLIFrameElement; + const error = element.querySelector("div[class=error]") as HTMLDivElement; + const tryAgain = element.querySelector(".try-again") as HTMLButtonElement; + + expect(error.style.display).to.equal("none"); + + // Make the annotator throw an error so the load handler won't succeed. + saveLastReadingPosition.throws(); + + iframe.src = "http://example.com/item-1.html"; + await pause(); + expect(error.style.display).not.to.equal("none"); + + // If trying again fails, the error message stays up. + click(tryAgain); + await pause(); + expect(error.style.display).not.to.equal("none"); + + // If trying again succeeds, it goes away. + saveLastReadingPosition.returns(new Promise(async (resolve) => resolve())); + click(tryAgain); + await pause(); + expect(error.style.display).to.equal("none"); + }); }); describe("table of contents", () => { diff --git a/test/ServiceWorkerCacherTests.ts b/test/ServiceWorkerCacherTests.ts index 978b10f..4993803 100644 --- a/test/ServiceWorkerCacherTests.ts +++ b/test/ServiceWorkerCacherTests.ts @@ -30,7 +30,7 @@ describe('ServiceWorkerCacher', () => { match = stub().returns(new Promise((resolve) => { resolve(matchResult); })); - addAll = stub(); + addAll = stub().returns(new Promise(resolve => resolve())); const cache = ({ match: match, addAll: addAll @@ -90,20 +90,26 @@ describe('ServiceWorkerCacher', () => { expect(register.args[1][0]).to.equal("../../../sw.js"); }); - it("should find a manifest that's already in the cache", async () => { + it("shouldn't cache anything if it has already successfully cached everything", async () => { mockCacheAPI("i'm in the cache"); - const cacher = new ServiceWorkerCacher(store, new URL("https://example.com/manifest.json")); + // A MockCacher that can have its status set externally. + class MockCacher extends ServiceWorkerCacher { + public setStatus(status: CacheStatus) { + this.cacheStatus = status; + } + } + + const cacher = new MockCacher(store, new URL("https://example.com/manifest.json")); + cacher.setStatus(CacheStatus.Downloaded); await cacher.enable(); - // The manifest cache was opened. - expect(open.callCount).to.equal(1); - expect(open.args[0][0]).to.equal("https://example.com/manifest.json"); + // The manifest cache was not opened. + expect(open.callCount).to.equal(0); - // The cache was checked for a match. - expect(match.callCount).to.equal(1); - expect(match.args[0][0]).to.equal("https://example.com/manifest.json"); + // The cache was not checked for a match. + expect(match.callCount).to.equal(0); - // Nothing was added to the cache since the manifest was already there. + // Nothing was added to the cache. expect(addAll.callCount).to.equal(0); }); @@ -157,6 +163,19 @@ describe('ServiceWorkerCacher', () => { it("should provide status updates when downloading and when caching is complete", async () => { mockCacheAPI("i'm in the cache"); + const manifest = new Manifest({ + spine: [ + { href: "spine-item-1.html" }, + { href: "spine-item-2.html" } + ], + resources: [ + { href: "resource-1.html" }, + { href: "resource-2.html" } + ] + }, new URL("https://example.com/manifest.json")); + + await store.set("manifest", JSON.stringify(manifest)); + const cacher = new ServiceWorkerCacher(store, new URL("https://example.com/manifest.json")); const callback = stub(); @@ -194,4 +213,46 @@ describe('ServiceWorkerCacher', () => { expect(callback.args[2][0]).to.equal(CacheStatus.Error); }); }); + + describe('#getStatus', () => { + it("should provide status updates when downloading and when caching is complete", async () => { + mockCacheAPI("i'm in the cache"); + const manifest = new Manifest({ + spine: [ + { href: "spine-item-1.html" }, + { href: "spine-item-2.html" } + ], + resources: [ + { href: "resource-1.html" }, + { href: "resource-2.html" } + ] + }, new URL("https://example.com/manifest.json")); + + await store.set("manifest", JSON.stringify(manifest)); + + const cacher = new ServiceWorkerCacher(store, new URL("https://example.com/manifest.json")); + + expect(cacher.getStatus()).to.equal(CacheStatus.Uncached); + + cacher.enable(); + expect(cacher.getStatus()).to.equal(CacheStatus.Downloading); + + await pause(); + expect(cacher.getStatus()).to.equal(CacheStatus.Downloaded); + }); + + it("should provide status updates when there's an error", async () => { + mockCacheAPI("i'm in the cache"); + open.returns(new Promise((_, reject) => reject())); + const cacher = new ServiceWorkerCacher(store, new URL("https://example.com/manifest.json")); + + expect(cacher.getStatus()).to.equal(CacheStatus.Uncached); + + cacher.enable(); + expect(cacher.getStatus()).to.equal(CacheStatus.Downloading); + + await pause(); + expect(cacher.getStatus()).to.equal(CacheStatus.Error); + }); + }); }); \ No newline at end of file