diff --git a/app/public/custom-scripts.json b/app/public/custom-scripts.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/app/public/custom-scripts.json @@ -0,0 +1 @@ +[] diff --git a/src/mixins/communicationBridgeUtils.js b/src/mixins/communicationBridgeUtils.js index 4f1bc9a00..7b83254fd 100644 --- a/src/mixins/communicationBridgeUtils.js +++ b/src/mixins/communicationBridgeUtils.js @@ -8,6 +8,8 @@ * See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ +import { runCustomScriptsForNewContent } from 'docc-render/utils/custom-scripts'; + export default { inject: { performanceMetricsEnabled: { @@ -40,6 +42,15 @@ export default { time, }, }); + + // Run custom scripts now that the page's content is in the DOM, so that they can customize + // it. When a page relies on data being provided via $bridge, this is first called before + // there's any content, so skip running custom scripts until there is. + if (this.topicData) { + runCustomScriptsForNewContent().catch((error) => { + console.error('Failed to run custom scripts:', error); + }); + } }, /** * @param {Object} data diff --git a/src/utils/custom-scripts.js b/src/utils/custom-scripts.js new file mode 100644 index 000000000..d78aac5a5 --- /dev/null +++ b/src/utils/custom-scripts.js @@ -0,0 +1,254 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2021 Apple Inc. and the Swift project authors + * Licensed under Apache License v2.0 with Runtime Library Exception + * + * See https://swift.org/LICENSE.txt for license information + * See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import { + copyPresentProperties, + copyPropertyIfPresent, + has, + mustNotHave, +} from 'docc-render/utils/object-properties'; +import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper'; + +/** Enum for the allowed values of the `run` property in a custom script. */ +const Run = { + onLoad: 'on-load', + onLoadAndNavigate: 'on-load-and-navigate', + onNavigate: 'on-navigate', +}; + +/** + * Returns whether the custom script should be run when the reader navigates to a subpage. + * @param {object} customScript + * @returns {boolean} Returns whether the custom script has a `run` property with a value of + * "on-load" or "on-load-and-navigate". Also returns true if the `run` property is absent. + */ +function shouldRunOnPageLoad(customScript) { + return !has(customScript, 'run') + || customScript.run === Run.onLoad || customScript.run === Run.onLoadAndNavigate; +} + +/** + * Returns whether the custom script should be run when the reader navigates to a topic. + * @param {object} customScript + * @returns {boolean} Returns whether the custom script has a `run` property with a value of + * "on-navigate" or "on-load-and-navigate". + */ +function shouldRunOnNavigate(customScript) { + return has(customScript, 'run') + && (customScript.run === Run.onNavigate || customScript.run === Run.onLoadAndNavigate); +} + +/** + * Gets the URL for a local custom script given its name. + * @param {string} customScriptName The name of the custom script as spelled in + * custom-scripts.json. While the actual filename (in the custom-scripts directory) is always + * expected to end in ".js", the name in custom-scripts.json may or may not include the ".js" + * extension. + * @returns {string} The absolute URL where the script is, accounting for baseURL. + * @example + * // if baseURL is '/foo' + * urlGivenScriptName('hello-world') // http://localhost:8080/foo/hello-world.js + * urlGivenScriptName('hello-world.js') // http://localhost:8080/foo/hello-world.js + */ +function urlGivenScriptName(customScriptName) { + let scriptNameWithExtension = customScriptName; + + // If the provided name does not already include the ".js" extension, add it. + if (customScriptName.slice(-3) !== '.js') { + scriptNameWithExtension = `${customScriptName}.js`; + } + + return resolveAbsoluteUrl(['', 'custom-scripts', scriptNameWithExtension]); +} + +/** + * Describes a custom script for error messages. + * @param {object} customScript + * @returns {string} The script's URL or name, or its JSON representation if it has neither. + */ +function describe(customScript) { + return customScript.url || customScript.name || JSON.stringify(customScript); +} + +/** + * Creates an HTMLScriptElement for the custom script. + * @param {object} customScript The custom script. + * @returns {HTMLScriptElement} A script element that runs the custom script once it's added to the + * document. + */ +function createScriptElement(customScript) { + const scriptElement = document.createElement('script'); + + copyPropertyIfPresent('type', customScript, scriptElement); + + if (has(customScript, 'url')) { + mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.'); + + scriptElement.src = customScript.url; + + // Dynamically-created script elements are `async` by default, meaning that they run in + // whatever order they finish loading. But custom scripts should run in the order in which + // they're listed in custom-scripts.json, so that, for example, a library's configuration + // script can run before the library itself. + // + // Therefore, remove the script element's `async` unless async is explicitly enabled. + scriptElement.async = customScript.async || false; + + copyPresentProperties(['integrity'], customScript, scriptElement); + + // If `integrity` is set on an external script, then CORS must be enabled as well. + if (has(customScript, 'integrity')) { + scriptElement.crossOrigin = 'anonymous'; + } + } else if (has(customScript, 'name')) { + scriptElement.src = urlGivenScriptName(customScript.name); + scriptElement.async = customScript.async || false; + + copyPresentProperties(['async', 'integrity'], customScript, scriptElement); + } else { + throw new Error('Custom script does not have `url` or `name` properties.'); + } + + return scriptElement; +} + +/** + * Add an HTMLScriptElement containing the custom script to the document's head, which runs the + * script on page load. + * @param {object} customScript The custom script, assuming it should be run on page load. + */ +function addScriptElement(customScript) { + document.head.appendChild(createScriptElement(customScript)); +} + +/** + * Runs the custom script again by adding a new HTMLScriptElement for it to the document's head, + * and removes the element once the script has run or failed to load. Useful for running a custom + * script anytime after page load, namely when the reader navigates to a subpage. + * + * Unlike evaluating the script's source code with `eval` or `new Function`, this works on websites + * whose Content Security Policy disallows `unsafe-eval`. + * @param {object} customScript The custom script, assuming it should be run on navigate. + * @returns {Promise} A promise that resolves once the script has run, or rejects if the + * custom script is invalid or fails to load. + */ +function rerunScriptElement(customScript) { + return new Promise((resolve, reject) => { + // If the custom script is invalid, this throws, which rejects the promise. + const scriptElement = createScriptElement(customScript); + + scriptElement.addEventListener('load', () => { + scriptElement.remove(); + resolve(); + }, { once: true }); + scriptElement.addEventListener('error', () => { + scriptElement.remove(); + reject(new Error(`Failed to load ${scriptElement.src}.`)); + }, { once: true }); + + document.head.appendChild(scriptElement); + }); +} + +/** + * Fetches the custom scripts declared in custom-scripts.json from the server. + * @returns {Promise} The custom scripts, or an empty array if the file is absent. + */ +async function fetchCustomScriptsFromServer() { + const customScriptsFileName = 'custom-scripts.json'; + const url = resolveAbsoluteUrl(`/${customScriptsFileName}`); + + const response = await fetch(url); + if (!response.ok) { + // If the file is absent, fail silently. + return []; + } + + const customScripts = await response.json(); + if (!Array.isArray(customScripts)) { + throw new Error(`Content of ${customScriptsFileName} should be an array.`); + } + + return customScripts; +} + +/** The custom scripts, fetched once and then reused for every routing event. */ +let customScriptsPromise; + +/** + * Fetches the custom scripts declared in custom-scripts.json, only requesting the file once. + * @returns {Promise} The custom scripts, or an empty array if the file is absent. + */ +function fetchCustomScripts() { + if (!customScriptsPromise) { + customScriptsPromise = fetchCustomScriptsFromServer().catch((error) => { + // Don't cache the failure, so that the next routing event tries again. + customScriptsPromise = undefined; + throw error; + }); + } + return customScriptsPromise; +} + +/** + * Runs all "on-load" and "on-load-and-navigate" scripts. + * @returns {Promise} + */ +export async function runCustomPageLoadScripts() { + const customScripts = await fetchCustomScripts(); + + // An invalid script doesn't prevent the other scripts from running. + customScripts.filter(shouldRunOnPageLoad).forEach((customScript) => { + try { + addScriptElement(customScript); + } catch (error) { + console.error(`Failed to add custom script (${describe(customScript)}):`, error); + } + }); +} + +/** + * Runs all "on-navigate" and "on-load-and-navigate" scripts. + * @returns {Promise} A promise that resolves once all the scripts have run or failed. + */ +export async function runCustomNavigateScripts() { + const customScripts = (await fetchCustomScripts()).filter(shouldRunOnNavigate); + + // The script elements are all added at once so that the browser loads the scripts in parallel, + // but since they aren't `async` (unless explicitly enabled), the scripts still run in the order + // in which they're listed. A script that's invalid or fails to load doesn't prevent the other + // scripts from running. + const results = await Promise.allSettled(customScripts.map(rerunScriptElement)); + results.forEach((result, index) => { + if (result.status === 'rejected') { + console.error( + `Failed to run custom script (${describe(customScripts[index])}):`, + result.reason, + ); + } + }); +} + +/** Whether the page load scripts have run, meaning that any new content is from a navigation. */ +let hasRunPageLoadScripts = false; + +/** + * Runs the custom scripts for a page's new content, once it has been rendered: the page load + * scripts for the first content, and the navigation scripts for any content after that. + * @returns {Promise} + */ +export async function runCustomScriptsForNewContent() { + if (hasRunPageLoadScripts) { + await runCustomNavigateScripts(); + } else { + hasRunPageLoadScripts = true; + await runCustomPageLoadScripts(); + } +} diff --git a/src/utils/object-properties.js b/src/utils/object-properties.js new file mode 100644 index 000000000..a0c32d7d9 --- /dev/null +++ b/src/utils/object-properties.js @@ -0,0 +1,48 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2021 Apple Inc. and the Swift project authors + * Licensed under Apache License v2.0 with Runtime Library Exception + * + * See https://swift.org/LICENSE.txt for license information + * See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +/** Convenient shorthand for `Object.hasOwn`. */ +export const has = Object.hasOwn; +/** + * Copies source.property, if it exists, to destination.property. + * @param {string} property + * @param {object} source + * @param {object} destination + */ +export function copyPropertyIfPresent(property, source, destination) { + if (has(source, property)) { + // eslint-disable-next-line no-param-reassign + destination[property] = source[property]; + } +} + +/** + * Copies all specified properties present in the source to the destination. + * @param {string[]} properties + * @param {object} source + * @param {object} destination + */ +export function copyPresentProperties(properties, source, destination) { + properties.forEach((property) => { + copyPropertyIfPresent(property, source, destination); + }); +} + +/** + * Throws an error if `object` has the property `property`. + * @param {object} object + * @param {string} property + * @param {string} errorMessage + */ +export function mustNotHave(object, property, errorMessage) { + if (has(object, property)) { + throw new Error(errorMessage); + } +} diff --git a/src/utils/theme-settings.js b/src/utils/theme-settings.js index fad2c8145..4468210da 100644 --- a/src/utils/theme-settings.js +++ b/src/utils/theme-settings.js @@ -23,7 +23,7 @@ export const themeSettingsState = { export const baseUrl = new URL(document.baseURI).pathname; /** - * Method to fetch the theme settings and store in local module state. + * Fetches the theme settings and store in local module state. * Method is called before Vue boots in `main.js`. * @return {Promise<{}>} */ diff --git a/tests/unit/imagePreloading/imagePreloading.spec.js b/tests/unit/imagePreloading/imagePreloading.spec.js index cd9e0fc6f..e9294469d 100644 --- a/tests/unit/imagePreloading/imagePreloading.spec.js +++ b/tests/unit/imagePreloading/imagePreloading.spec.js @@ -22,6 +22,11 @@ import hide from 'docc-render/directives/hide'; import Topic from 'docc-render/views/Topic.vue'; import createRouterInstance from 'docc-render/setup-utils/SwiftDocCRenderRouter'; +// Custom scripts are tested separately; don't fetch custom-scripts.json when content is rendered. +jest.mock('docc-render/utils/custom-scripts', () => ({ + runCustomScriptsForNewContent: jest.fn().mockResolvedValue(), +})); + const router = createRouterInstance(); jest.mock('docc-render/utils/theme-settings'); diff --git a/tests/unit/mixins/communicationBridgeUtils.spec.js b/tests/unit/mixins/communicationBridgeUtils.spec.js new file mode 100644 index 000000000..b417fad71 --- /dev/null +++ b/tests/unit/mixins/communicationBridgeUtils.spec.js @@ -0,0 +1,55 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2026 Apple Inc. and the Swift project authors + * Licensed under Apache License v2.0 with Runtime Library Exception + * + * See https://swift.org/LICENSE.txt for license information + * See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import { shallowMount } from '@vue/test-utils'; +import communicationBridgeUtils from 'docc-render/mixins/communicationBridgeUtils'; +import { runCustomScriptsForNewContent } from 'docc-render/utils/custom-scripts'; + +jest.mock('docc-render/utils/custom-scripts', () => ({ + runCustomScriptsForNewContent: jest.fn().mockResolvedValue(), +})); + +const createWrapper = topicData => shallowMount({ + mixins: [communicationBridgeUtils], + data: () => ({ topicData }), + render: () => null, +}, { + mocks: { + $bridge: { send: jest.fn() }, + }, +}); + +describe('communicationBridgeUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('newContentMounted', () => { + it('sends a "rendered" message to the bridge', () => { + const wrapper = createWrapper({ foo: 'bar' }); + wrapper.vm.newContentMounted(); + expect(wrapper.vm.$bridge.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'rendered', + })); + }); + + it('runs custom scripts for the new content', () => { + const wrapper = createWrapper({ foo: 'bar' }); + wrapper.vm.newContentMounted(); + expect(runCustomScriptsForNewContent).toHaveBeenCalledTimes(1); + }); + + it('does not run custom scripts before there is any content', () => { + const wrapper = createWrapper(null); + wrapper.vm.newContentMounted(); + expect(runCustomScriptsForNewContent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/utils/custom-scripts.spec.js b/tests/unit/utils/custom-scripts.spec.js new file mode 100644 index 000000000..210153e0a --- /dev/null +++ b/tests/unit/utils/custom-scripts.spec.js @@ -0,0 +1,354 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2022 Apple Inc. and the Swift project authors + * Licensed under Apache License v2.0 with Runtime Library Exception + * + * See https://swift.org/LICENSE.txt for license information + * See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +let runCustomPageLoadScripts; +let runCustomNavigateScripts; +let runCustomScriptsForNewContent; + +let jsonMock; +let fetchMock; + +const createElementMock = jest.fn(document.createElement); +document.createElement = createElementMock; + +// Spy on document.head.appendChild to check which script elements are added, even after they're +// removed again. +const appendChildSpy = jest.spyOn(document.head, 'appendChild'); + +/** + * Sets the custom-scripts.json array fetched by the fetchMock. + * @param {object[]} customScripts + */ +function setCustomScripts(customScripts) { + // The jsonMock is different for each test, so it must be reset. + jsonMock = jest.fn().mockResolvedValue(customScripts); + + fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: jsonMock, + }); + + window.fetch = fetchMock; +} + +/** + * Returns the script elements that were added to the document's head, in the order in which they + * were added. + * @returns {HTMLScriptElement[]} + */ +function addedScriptElements() { + return appendChildSpy.mock.calls.map(([element]) => element); +} + +/** + * Waits for pending promises, like the mocked fetch of custom-scripts.json, to settle. + * @returns {Promise} + */ +function flushPromises() { + return new Promise((resolve) => { + setTimeout(resolve); + }); +} + +/** + * Runs the navigation scripts, simulating each added script element loading, since jsdom doesn't + * load scripts. + * @param {(element: HTMLScriptElement) => string} eventType The event to dispatch on each element. + * @returns {Promise} + */ +async function runNavigateScripts(eventType = () => 'load') { + const promise = runCustomNavigateScripts(); + await flushPromises(); + addedScriptElements().forEach(element => element.dispatchEvent(new Event(eventType(element)))); + return promise; +} + +describe('custom-scripts', () => { + let consoleErrorSpy; + + beforeEach(() => { + jest.clearAllMocks(); + + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // The module caches custom-scripts.json, so load a fresh copy of it for each test. + jest.resetModules(); + /* eslint-disable global-require */ + ({ + runCustomPageLoadScripts, + runCustomNavigateScripts, + runCustomScriptsForNewContent, + } = require('docc-render/utils/custom-scripts')); + /* eslint-enable global-require */ + + document.head.innerHTML = ''; + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe('runCustomPageLoadScripts', () => { + it('creates a script element for each explicit or implicit "on-load" script', async () => { + setCustomScripts([ + { + url: 'https://www.example.js', + async: true, + run: 'on-load', + }, + { name: 'my-local-script' }, + ]); + + await runCustomPageLoadScripts(); + + expect(createElementMock).toHaveBeenCalledTimes(2); + }); + + it('runs "on-load-and-navigate" scripts as well', async () => { + setCustomScripts([ + { + name: 'my-local-script.js', + run: 'on-load-and-navigate', + }, + ]); + + await runCustomPageLoadScripts(); + + expect(createElementMock).toHaveBeenCalledTimes(1); + }); + + it('does not run "on-navigate" scripts', async () => { + setCustomScripts([ + { + name: 'my-local-script', + run: 'on-navigate', + }, + ]); + + await runCustomPageLoadScripts(); + + expect(createElementMock).toHaveBeenCalledTimes(0); + }); + + it('configures script elements from the custom scripts', async () => { + setCustomScripts([ + { url: 'https://www.example.com/external.js', integrity: 'sha384-external' }, + { name: 'my-local-script' }, + { name: 'my-async-script.js', async: true }, + ]); + + await runCustomPageLoadScripts(); + + const [external, local, asyncScript] = addedScriptElements(); + + expect(external.src).toBe('https://www.example.com/external.js'); + expect(external.integrity).toBe('sha384-external'); + expect(external.crossOrigin).toBe('anonymous'); + expect(external.async).toBe(false); + + expect(local.src).toMatch(/\/custom-scripts\/my-local-script\.js$/); + expect(local.async).toBe(false); + + expect(asyncScript.src).toMatch(/\/custom-scripts\/my-async-script\.js$/); + expect(asyncScript.async).toBe(true); + }); + + it('skips invalid scripts, adding the other scripts', async () => { + setCustomScripts([ + // A script must have either a `url` or a `name`. + { async: true }, + // A script can't have both a `url` and a `name`. + { url: 'https://www.example.com/script.js', name: 'my-local-script' }, + { name: 'my-other-local-script' }, + ]); + + await runCustomPageLoadScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements).toHaveLength(1); + expect(scriptElements[0].src).toMatch(/\/custom-scripts\/my-other-local-script\.js$/); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('runCustomNavigateScripts', () => { + it('runs "on-navigate" and "on-load-and-navigate" scripts', async () => { + setCustomScripts([ + { + name: 'script1.js', + run: 'on-navigate', + }, + { + name: 'script2', + run: 'on-load-and-navigate', + }, + { + name: 'script3.js', + run: 'on-load-and-navigate', + }, + ]); + + await runNavigateScripts(); + + expect(addedScriptElements()).toHaveLength(3); + }); + + it('adds the scripts in the order in which they are listed, without `async`', async () => { + setCustomScripts([ + { name: 'first.js', run: 'on-navigate' }, + { url: 'https://www.example.com/second.js', run: 'on-navigate' }, + ]); + + await runNavigateScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements.map(element => element.src)).toEqual([ + expect.stringMatching(/\/custom-scripts\/first\.js$/), + 'https://www.example.com/second.js', + ]); + // Script elements that aren't `async` run in the order in which they're added. + expect(scriptElements.map(element => element.async)).toEqual([false, false]); + }); + + it('adds a new script element each time the reader navigates', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-navigate' }]); + + await runNavigateScripts(); + await runNavigateScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements).toHaveLength(2); + expect(scriptElements[0]).not.toBe(scriptElements[1]); + expect(scriptElements[0].src).toBe(scriptElements[1].src); + }); + + it('removes each script element once its script has run or failed to load', async () => { + setCustomScripts([ + { name: 'loads.js', run: 'on-navigate' }, + { name: 'fails.js', run: 'on-navigate' }, + ]); + + const promise = runCustomNavigateScripts(); + await flushPromises(); + + const [loads, fails] = addedScriptElements(); + expect(document.head.contains(loads)).toBe(true); + expect(document.head.contains(fails)).toBe(true); + + loads.dispatchEvent(new Event('load')); + fails.dispatchEvent(new Event('error')); + await promise; + + expect(document.head.contains(loads)).toBe(false); + expect(document.head.contains(fails)).toBe(false); + }); + + it('waits for the scripts to run before resolving', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-navigate' }]); + + let hasResolved = false; + const promise = runCustomNavigateScripts().then(() => { hasResolved = true; }); + await flushPromises(); + expect(hasResolved).toBe(false); + + addedScriptElements()[0].dispatchEvent(new Event('load')); + await promise; + expect(hasResolved).toBe(true); + }); + + it('runs the other scripts when one of them fails to load', async () => { + setCustomScripts([ + { name: 'missing.js', run: 'on-navigate' }, + { name: 'present.js', run: 'on-navigate' }, + ]); + + await runNavigateScripts(element => (element.src.endsWith('missing.js') ? 'error' : 'load')); + + expect(addedScriptElements()).toHaveLength(2); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('runs the other scripts when one of them is invalid', async () => { + setCustomScripts([ + // A script must have either a `url` or a `name`. + { run: 'on-navigate' }, + { name: 'valid.js', run: 'on-navigate' }, + ]); + + await runNavigateScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements).toHaveLength(1); + expect(scriptElements[0].src).toMatch(/\/custom-scripts\/valid\.js$/); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('does not run scripts without a `run` property', async () => { + setCustomScripts([{ name: 'my-script' }]); + + await runCustomNavigateScripts(); + + expect(addedScriptElements()).toHaveLength(0); + }); + }); + + describe('runCustomScriptsForNewContent', () => { + it('runs the page load scripts the first time, and the navigation scripts after that', async () => { + setCustomScripts([ + { name: 'on-load.js' }, + { name: 'on-navigate.js', run: 'on-navigate' }, + ]); + + await runCustomScriptsForNewContent(); + expect(addedScriptElements().map(element => element.src)).toEqual([ + expect.stringMatching(/\/on-load\.js$/), + ]); + + // Navigation scripts are awaited, so simulate the script loading. + const promise = runCustomScriptsForNewContent(); + await flushPromises(); + addedScriptElements()[1].dispatchEvent(new Event('load')); + await promise; + expect(addedScriptElements().map(element => element.src)).toEqual([ + expect.stringMatching(/\/on-load\.js$/), + expect.stringMatching(/\/on-navigate\.js$/), + ]); + }); + }); + + describe('fetching custom-scripts.json', () => { + it('only requests the file once', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-load-and-navigate' }]); + + await runCustomPageLoadScripts(); + await runNavigateScripts(); + await runNavigateScripts(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + // The script still runs on page load and on each navigation. + expect(addedScriptElements()).toHaveLength(3); + }); + + it('requests the file again after failing to fetch it', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-navigate' }]); + const successfulFetch = fetchMock; + fetchMock = jest.fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockImplementation(successfulFetch); + window.fetch = fetchMock; + + await expect(runCustomNavigateScripts()).rejects.toThrow('Failed to fetch'); + await runNavigateScripts(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(addedScriptElements()).toHaveLength(1); + }); + }); +}); diff --git a/tests/unit/views/DocumentationTopic.spec.js b/tests/unit/views/DocumentationTopic.spec.js index 578edb3ba..c20cd9dae 100644 --- a/tests/unit/views/DocumentationTopic.spec.js +++ b/tests/unit/views/DocumentationTopic.spec.js @@ -19,6 +19,11 @@ import onThisPageRegistrator from '@/mixins/onThisPageRegistrator'; import { getSetting } from 'docc-render/utils/theme-settings'; import { flushPromises } from '../../../test-utils'; +// Custom scripts are tested separately; don't fetch custom-scripts.json when content is rendered. +jest.mock('docc-render/utils/custom-scripts', () => ({ + runCustomScriptsForNewContent: jest.fn().mockResolvedValue(), +})); + jest.mock('docc-render/mixins/onThisPageRegistrator'); jest.mock('docc-render/utils/theme-settings'); diff --git a/tests/unit/views/Topic.spec.js b/tests/unit/views/Topic.spec.js index 35caceeb6..f0ed5e01d 100644 --- a/tests/unit/views/Topic.spec.js +++ b/tests/unit/views/Topic.spec.js @@ -16,6 +16,11 @@ import Tutorial from 'docc-render/components/Tutorial.vue'; import onPageLoadScrollToFragment from 'docc-render/mixins/onPageLoadScrollToFragment'; import { fetchDataForRouteEnter } from '@/utils/data'; +// Custom scripts are tested separately; don't fetch custom-scripts.json when content is rendered. +jest.mock('docc-render/utils/custom-scripts', () => ({ + runCustomScriptsForNewContent: jest.fn().mockResolvedValue(), +})); + jest.mock('docc-render/mixins/onPageLoadScrollToFragment'); jest.mock('@/utils/data'); diff --git a/tests/unit/views/TutorialsOverview.spec.js b/tests/unit/views/TutorialsOverview.spec.js index 5812258fb..b05b2371e 100644 --- a/tests/unit/views/TutorialsOverview.spec.js +++ b/tests/unit/views/TutorialsOverview.spec.js @@ -12,6 +12,11 @@ import { shallowMount } from '@vue/test-utils'; import TutorialsOverview from 'docc-render/views/TutorialsOverview.vue'; import onPageLoadScrollToFragment from 'docc-render/mixins/onPageLoadScrollToFragment'; +// Custom scripts are tested separately; don't fetch custom-scripts.json when content is rendered. +jest.mock('docc-render/utils/custom-scripts', () => ({ + runCustomScriptsForNewContent: jest.fn().mockResolvedValue(), +})); + jest.mock('docc-render/mixins/onPageLoadScrollToFragment'); const { Overview } = TutorialsOverview.components;