From 3fe57ce4167ecc538954df542a32f03a2a7f1d73 Mon Sep 17 00:00:00 2001 From: Grant Cleary Date: Tue, 26 Mar 2024 10:38:51 -0400 Subject: [PATCH 1/6] Create host and client functionality for context provider --- eslintrc.json => .eslintrc.json | 0 .github/workflows/release.yml | 3 - README.md | 91 +++++++ package-lock.json | 98 ++++++- package.json | 9 +- src/client/client-internal.js | 143 ++++++++++ src/client/client.js | 9 + src/host/host-internal.js | 116 ++++++++ src/host/host.js | 13 + test/client.test.js | 465 ++++++++++++++++++++++++++++++++ test/host.test.js | 390 +++++++++++++++++++++++++++ 11 files changed, 1332 insertions(+), 5 deletions(-) rename eslintrc.json => .eslintrc.json (100%) create mode 100644 src/client/client-internal.js create mode 100644 src/client/client.js create mode 100644 src/host/host-internal.js create mode 100644 src/host/host.js create mode 100644 test/client.test.js create mode 100644 test/host.test.js diff --git a/eslintrc.json b/.eslintrc.json similarity index 100% rename from eslintrc.json rename to .eslintrc.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ad3405..8342717 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,3 @@ jobs: MINOR_RELEASE_WITH_LMS: true NPM: true RALLY_API_KEY: ${{ secrets.RALLY_API_KEY }} - - name: Get new git HEAD - id: git - run: echo "head=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT diff --git a/README.md b/README.md index a2a27a1..60e89a6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,97 @@ Install from CodeArtifact: npm install @d2l/lms-context-provider ``` +## Usage + +### Configuring a Host + +#### Initializing + +Initializing a host should rarely be necessary. Within a Brightspace instance, this will generally be handled by BSI via our MVC and legacy frameworks. If you do need to initialize a host, simply import and execute the `initialize` function. + +```js +import { initialize } from '@d2l/lms-context-provider/host.js'; + +initialize(); +``` + +#### Registering Plugins + +To register a host plugin, import and execute the `registerPlugin` function on a page where a host has already been initialized. The provided context type should be unique per page. If your plugin needs to return data to a client, it should provide a `tryGetCallback` as the second argument. If it allows subscriptions to change events, then it should provide a `subscribeCallback` as the third argument. + +```js +import { registerPlugin } from '@d2l/lms-context/provider/host.js'; + +function tryGetCallback(options) { + // This can be asynchronous. + const returnVal = doSomeWork(options); + return returnVal; +} + +function subscribeCallback(onChange, options) { + // this can be asynchronous. + const returnVal = doSomeWork(options); + + // Options are defined by the host, not the plugin. sendImmediate indicates the change handler should be invoked immediately. + if (options.sendImmediate) { + // Expects an object as its only argument. + onChange({ val: returnVal }); + } + + // onChange event should be subscribed to future changes. + registerOnChangeEvent(onChange); +} + +registerPlugin('my-context-type', tryGetCallback, subscribeCallback); +``` + +#### Framed Clients + +When working with a client inside an iframe, the host page needs to explicitly allow that iframe. To do this, import and execute `allowFrame` from the host page. The host must already be initialized. The first argument must be the iframe element itself. The second argument should be the expected origin. Requests from clients within iframes that have not explicitly bene allowed or that come from a different origin will be ignored. + +```js +import { allowFrame } from '@d2l/lms-context-provider/host.js'; + +const myFrame = document.createElement('iframe'); +document.body.append(myFrame); + +allowFrame(myFrame, window.location.origin); +``` + +### Using a Client + +#### Requesting Data + +When your library or component is expecting data to be returned, import `tryGet` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. The third argument is an optional callback to allow a consumer to subscribe to future changes to the data they're requesting. + +```js +import { tryGet } from '@d2l/lms-context-provider/client.js'; + +// This callback should accept a single argument, an object containing any relevant information from the host plugin. +function onChangeCallback(changedValues) { + if (changedValues.someChangedProp === 'someVal') { + doSomeWork(changedValues.someChangedProp); + } +} + +const val = await tryGet('my-context-type', { someProp: someVal }, onChangeCallback); +doSomeWork(val); +``` + +If no host plugin is registered to handle your request, or if the data being requested isn't available, the host will return `undefined`. The host plugin may also need to rely on asynchronous methods to return data, so your code should be resilient to receiving a promise that doesn't resolve or takes some time to resolve. + +#### Performing an Action + +If your library or component needs to initiate an action on the host but doesn't require return data, import `tryPerform` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. It is not possible to subscribe to change events using this function. + +```js +import { tryPerform } from '@d2l/lms-context-provider/client.js;' + +await tryPerform('my-context-type', { someProp: someVal }); +``` + +If no host plugin is registered to handle your request, or if the data being requested isn't available, this promise will immediately resolve and nothing will happen. As with the `tryGet` function, the host plugin may need to perform asynchronous actions to fulfill your request, so this promise may also never resolve, or may take some time to resolve. + ## Developing, Testing and Contributing After cloning the repo, run `npm install` to install dependencies. diff --git a/package-lock.json b/package-lock.json index fee080c..eb715ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@brightspace-ui/testing": "^1", "eslint": "^8", "eslint-config-brightspace": "^1", - "sinon": "^17" + "sinon": "^17", + "sinon-chai": "3" } }, "node_modules/@75lb/deep-merge": { @@ -2002,6 +2003,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "peer": true, + "engines": { + "node": "*" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2355,6 +2366,25 @@ ], "peer": true }, + "node_modules/chai": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "dev": true, + "peer": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chai-a11y-axe": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/chai-a11y-axe/-/chai-a11y-axe-1.5.0.tgz", @@ -2463,6 +2493,19 @@ "node": ">=8" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "peer": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2843,6 +2886,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "peer": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -4140,6 +4196,16 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "peer": true, + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -5724,6 +5790,16 @@ "node": ">=8" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "peer": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -6373,6 +6449,16 @@ "node": ">=8" } }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "peer": true, + "engines": { + "node": "*" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -7147,6 +7233,16 @@ "url": "https://opencollective.com/sinon" } }, + "node_modules/sinon-chai": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", + "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", + "dev": true, + "peerDependencies": { + "chai": "^4.0.0", + "sinon": ">=4.0.0" + } + }, "node_modules/sinon/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 386e9d5..fa601b7 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "type": "module", "version": "1.0.0", "repository": "https://github.com/BrightspaceUI/lms-context-provider.git", + "publishConfig": { + "registry": "https://d2l-569998014834.d.codeartifact.us-east-1.amazonaws.com/npm/private/" + }, "scripts": { "lint": "npm run lint:eslint", "lint:eslint": "eslint . --ext .js,.html", "test": "npm run test:unit", - "test:unit": "d2l-test-runner --files ./src/**/test/*.test.js" + "test:unit": "d2l-test-runner --files ./test/**/*.test.js" }, "author": "D2L Corporation", "license": "Apache-2.0", @@ -18,6 +21,10 @@ "eslint-config-brightspace": "^1", "sinon": "^17" }, + "exports": { + "./host.js": "./src/host/host.js", + "./client.js": "./src/client/client.js" + }, "files": [ "/src", "!demo", diff --git a/src/client/client-internal.js b/src/client/client-internal.js new file mode 100644 index 0000000..2bcf43a --- /dev/null +++ b/src/client/client-internal.js @@ -0,0 +1,143 @@ +const messageTimeoutMs = 75; +let oneTimeMessageListenerInitialized = false; +let subscriptionListenerInitialized = false; +let framed; + +const oneTimeCallbacks = new Map(); +const subscriptionCallbacks = new Map(); + +function handleOneTimeMessageResponse(e) { + if (!e?.data?.isContextProvider || !e.data.type) return; + + const callbacks = oneTimeCallbacks.get(e.data.type); + if (callbacks === undefined || callbacks.length === 0) return; + + callbacks.forEach(cb => cb(e.data.value)); + oneTimeCallbacks.set(e.data.type, []); +} + +async function sendMessage(message) { + if (!oneTimeMessageListenerInitialized) { + window.addEventListener('message', handleOneTimeMessageResponse); + oneTimeMessageListenerInitialized = true; + } + + return await new Promise(resolve => { + if (!oneTimeCallbacks.has(message.type)) { + oneTimeCallbacks.set(message.type, []); + } + + oneTimeCallbacks.get(message.type).push(resolve); + window.parent.postMessage(message, '*'); + }); +} + +function handleSubscribedChangeResponseEvent(e) { + handleSubscribedChangeResponse(e.detail); +} + +function handleSubscribedChangeResponseMessage(e) { + if (!e?.data?.isContextProvider) return; + handleSubscribedChangeResponse(e.data); +} + +function handleSubscribedChangeResponse(responseData) { + if (!responseData?.changedValues || !responseData.type) return; + const callbacks = subscriptionCallbacks.get(responseData.type); + callbacks.forEach(cb => cb(responseData.changedValues)); +} + +async function sendEvent(type, options, subscribe) { + if (await isFramed()) { + if (subscribe && !subscriptionListenerInitialized) { + window.addEventListener('message', handleSubscribedChangeResponseMessage); + subscriptionListenerInitialized = true; + } + + const message = { + isContextProvider: true, + type: type, + options: options, + subscribe: subscribe + }; + + return await Promise.race([ + sendMessage(message), + new Promise(resolve => setTimeout(() => resolve(undefined), messageTimeoutMs)) + ]); + } else { + if (subscribe && !subscriptionListenerInitialized) { + document.addEventListener('lms-context-change', handleSubscribedChangeResponseEvent); + subscriptionListenerInitialized = true; + } + + const event = new CustomEvent( + 'lms-context-request', { + detail: { + type: type, + options: options, + subscribe: subscribe + } + } + ); + + document.dispatchEvent(event); + return event.detail.value; + } +} + +// DO NOT IMPORT! Usage should be internal only; this is exported only for testing purposes. +export async function isFramed() { + if (framed !== undefined) return framed; + + try { + if (window === window.parent) { + framed = false; + return framed; + } + } catch (e) { + framed = false; + return framed; + } + + framed = await Promise.race([ + sendMessage({ isContextProvider: true, type: 'framed-request' }), + new Promise(resolve => setTimeout(() => resolve(false), messageTimeoutMs)) + ]); + + return framed; +} + +export async function tryGet(contextType, options, onChangeCallback) { + const subscribe = (onChangeCallback && typeof(onChangeCallback) === 'function') || false; + + // Send one-time request first to make sure it's responded to before any change listeners are registered. + const value = await sendEvent(contextType, options, subscribe); + + if (subscribe) { + if (!subscriptionCallbacks.has(contextType)) { + subscriptionCallbacks.set(contextType, []); + } + subscriptionCallbacks.get(contextType).push(onChangeCallback); + } + + return value; +} + +export async function tryPerform(actionType, options) { + await sendEvent(actionType, options, false); +} + +// DO NOT IMPORT! Used for testing only! +export function reset() { + window.removeEventListener('message', handleOneTimeMessageResponse); + window.removeEventListener('message', handleSubscribedChangeResponseMessage); + document.removeEventListener('lms-context-change', handleSubscribedChangeResponseEvent); + + oneTimeMessageListenerInitialized = false; + subscriptionListenerInitialized = false; + framed = undefined; + + oneTimeCallbacks.clear(); + subscriptionCallbacks.clear(); +} diff --git a/src/client/client.js b/src/client/client.js new file mode 100644 index 0000000..92279e3 --- /dev/null +++ b/src/client/client.js @@ -0,0 +1,9 @@ +import { tryGet as tryGetImpl, tryPerform as tryPerformImpl } from './client-internal.js'; + +export async function tryGet(contextType, options, onChangeCallback) { + return await tryGetImpl(contextType, options, onChangeCallback); +} + +export async function tryPerform(actionType, options) { + await tryPerformImpl(actionType, options); +} diff --git a/src/host/host-internal.js b/src/host/host-internal.js new file mode 100644 index 0000000..3a6144f --- /dev/null +++ b/src/host/host-internal.js @@ -0,0 +1,116 @@ +let initialized = false; + +const allowedFrames = new Map(); +const registeredPlugins = new Map(); +const subscriptionQueue = new Set(); + +function handleContextRequest(type, options, subscribe) { + const plugin = registeredPlugins.get(type); + + if (subscribe && !subscriptionQueue.has(type)) { + subscriptionQueue.add(type); + if (plugin && plugin.subscribe) plugin.subscribe(changedValues => sendChangeEvent(type, changedValues)); + } + + return plugin && plugin.tryGet && plugin.tryGet(options); +} + +function handleContextRequestEvent(e) { + if (!e.detail || !e.detail.type) return; + e.detail.value = handleContextRequest(e.detail.type, e.detail.options, e.detail.subscribe); +} + +function handleContextRequestMessage(e) { + if (!e.data.isContextProvider || !e.data.type || !/^(?:http|https):\/\//.test(e.origin)) return; + + let targetFrame; + for (const frame of allowedFrames.keys()) { + if (frame.contentWindow === e.source) { + targetFrame = frame; + break; + } + } + + if (!targetFrame || allowedFrames.get(targetFrame) !== e.origin) return; + + const messageType = e.data.type; + if (messageType === 'framed-request') { + targetFrame.contentWindow.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, e.origin); + return; + } + + const value = handleContextRequest(messageType, e.data.options, e.data.subscribe); + targetFrame.contentWindow.postMessage({ isContextProvider: true, type: messageType, value: value }, e.origin); +} + +function sendChangeEvent(type, changedValues) { + // Dispatch document-level change event for any non-framed consumers. + document.dispatchEvent(new CustomEvent( + 'lms-context-change', { + detail: { + type: type, + changedValues: changedValues + } + } + )); + + // Dispatch postMessages to registered frames + allowedFrames.forEach((origin, frame) => { + frame.contentWindow.postMessage({ isContextProvider: true, type: type, changedValues: changedValues }, origin); + }); +} + +export function initialize() { + if (initialized) return; + + window.addEventListener('message', handleContextRequestMessage); + document.addEventListener('lms-context-request', handleContextRequestEvent); + + initialized = true; +} + +export function allowFrame(frame, origin) { + if (!initialized) { + throw new Error(`lms-context-provider: Can't register frame with id ${frame.id}. Context provider host has not been initialized.`); + } + + if (allowedFrames.has(frame)) { + throw new Error(`lms-context-provider: A frame with id ${frame.id} has already been registered with this host.`); + } + + allowedFrames.set(frame, origin); +} + +export function registerPlugin(type, tryGetCallback, subscriptionCallback) { + if (!initialized) { + throw new Error(`lms-context-provider: Can't register plugin with type ${type}. Context provider host has not been initialized.`); + } + + if (registeredPlugins.has(type)) { + throw new Error(`lms-context-provider: A plugin with type ${type} has already been registered with this host.`); + } + + registeredPlugins.set(type, { + tryGet: tryGetCallback, + subscribe: subscriptionCallback + }); + + // Process any existing subscription requests + if (subscriptionQueue.has(type) && subscriptionCallback) { + subscriptionCallback(changedValues => sendChangeEvent(type, changedValues), { sendImmediate: true }); + } +} + +// DO NOT IMPORT! Used for testing only! +export function reset() { + if (!initialized) return; + + window.removeEventListener('message', handleContextRequestMessage); + document.removeEventListener('lms-context-request', handleContextRequestEvent); + + allowedFrames.clear(); + registeredPlugins.clear(); + subscriptionQueue.clear(); + + initialized = false; +} diff --git a/src/host/host.js b/src/host/host.js new file mode 100644 index 0000000..ceb0b80 --- /dev/null +++ b/src/host/host.js @@ -0,0 +1,13 @@ +import { allowFrame as allowFrameImpl, initialize as initializeImpl, registerPlugin as registerPluginImpl } from './host-internal.js'; + +export function initialize() { + initializeImpl(); +} + +export function allowFrame(frame, origin) { + allowFrameImpl(frame, origin); +} + +export function registerPlugin(name, tryGetCallback, subscriptionCallback) { + registerPluginImpl(name, tryGetCallback, subscriptionCallback); +} diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..750fa5e --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,465 @@ +import { aTimeout, expect, fixture, html } from '@brightspace-ui/testing'; +import { isFramed, reset, tryGet, tryPerform } from '../src/client/client-internal.js'; +import sinon from 'sinon'; + +const mockContextType = 'test-context'; +const mockOpts = { test: 'test' }; + +function assertFramedOneTimeRequestMessage(messageData, type, opts, subscribe) { + expect(messageData.isContextProvider).to.be.true; + expect(messageData.type).to.equal(type); + expect(messageData.options).to.deep.equal(opts); + expect(messageData.subscribe).to.equal(subscribe); +} + +describe('lms-context-provider client', () => { + + const sandbox = sinon.createSandbox(); + + afterEach(() => { + reset(); + sandbox.restore(); + }); + + const setUpIsFramedMessageListener = (frame, spy, respond) => { + const handleIsFramedMessage = e => { + frame.contentWindow.removeEventListener('message', handleIsFramedMessage); + if (spy) spy(e.data); + if (!respond) return; + + window.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, '*'); + }; + + frame.contentWindow.addEventListener('message', handleIsFramedMessage); + }; + + describe('is framed', () => { + + let mockFrame; + beforeEach(async() => { + mockFrame = await fixture(html``); + }); + + it('is not framed if the window is its own parent', async() => { + sandbox.stub(window, 'parent').value(window); + const listenerSpy = sandbox.spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, true); + + const framed = await isFramed(); + expect(framed).to.be.false; + expect(listenerSpy).not.to.have.been.called; + }); + + it('is not framed if accessing the window parent throws', async() => { + sandbox.stub(window, 'parent').throws(); + const listenerSpy = sandbox.spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, true); + + const framed = await isFramed(); + expect(framed).to.be.false; + expect(listenerSpy).not.to.have.been.called; + }); + + it('is not framed if the host does not respond to an is-framed request', async() => { + sandbox.stub(window, 'parent').value(mockFrame.contentWindow); + const listenerSpy = sandbox.spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, false); + + const framed = await isFramed(); + expect(framed).to.be.false; + expect(listenerSpy).to.have.been.calledOnce; + }); + + it('is framed if the host responds to an is-framed request', async() => { + sandbox.stub(window, 'parent').value(mockFrame.contentWindow); + const listenerSpy = sandbox.spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, true); + + const framed = await isFramed(); + expect(framed).to.be.true; + expect(listenerSpy).to.have.been.calledOnce; + + // Validate message params + expect(listenerSpy.args[0]).to.have.length(1); + const messageData = listenerSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, 'framed-request', undefined, undefined); + }); + + }); + + describe('framed client', () => { + + const sendResponseMessage = (isContextProvider, returnVal, type) => { + window.postMessage({ isContextProvider: isContextProvider, type: type, options: mockOpts, value: returnVal }, '*'); + }; + + const sendSubscriptionChangeMessage = (isContextProvider, type, changedValues) => { + window.postMessage({ isContextProvider: isContextProvider, type: type, changedValues: changedValues }, '*'); + }; + + const setUpMockHostMessageListener = (frame, spy, returnVal, isContextProvider, omitType) => { + isContextProvider = isContextProvider ?? true; + + const handleMessage = e => { + // Shortcut past framed-requests, as we're testing isFramed separately + if (e.data.type === 'framed-request') { + window.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, '*'); + return; + } + + frame.contentWindow.removeEventListener('message', handleMessage); + if (spy) spy(e.data); + if (!returnVal) return; + + sendResponseMessage(isContextProvider, returnVal, omitType ? undefined : mockContextType); + }; + + frame.contentWindow.addEventListener('message', handleMessage); + }; + + let mockFrame; + beforeEach(async() => { + mockFrame = await fixture(html``); + sandbox.stub(window, 'parent').value(mockFrame.contentWindow); + }); + + describe('tryGet', () => { + + it('returns requested data when provided by the host', async() => { + const testVal = 'testVal'; + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, testVal); + + const val = await tryGet(mockContextType, mockOpts); + expect(val).to.equal(testVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('returns correct requested data when provided by the host on subsequent calls', async() => { + const firstTestVal = 'testVal'; + const secondTestVal = 'otherTestVal'; + + setUpMockHostMessageListener(mockFrame, undefined, firstTestVal); + await tryGet(mockContextType, mockOpts); + + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, secondTestVal); + + const secondVal = await tryGet(mockContextType, mockOpts); + expect(secondVal).to.equal(secondTestVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('returns undefined when the host does not respond', async() => { + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy); + + const val = await tryGet(mockContextType, mockOpts); + expect(val).to.be.undefined; + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('ignores host response if isContextProvider is not provided in message', async() => { + const testVal = 'testVal'; + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, testVal, false); + + const val = await tryGet(mockContextType, mockOpts); + expect(val).to.be.undefined; + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('ignores host response if type is not provided in message', async() => { + const testVal = 'testVal'; + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, testVal, true, true); + + const val = await tryGet(mockContextType, mockOpts); + expect(val).to.be.undefined; + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('does not send subscribe event if onChange callback is not a function', async() => { + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, undefined); + + await tryGet(mockContextType, mockOpts, 'notAFunction'); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('executes onChange callback when valid subscription change message is received', async() => { + const testValues = { + testVal: 'testVal' + }; + + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, undefined); + + const subscriptionSpy = sandbox.spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + // Validate subscription info sent with request + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, true); + + sendSubscriptionChangeMessage(true, mockContextType, testValues); + await aTimeout(50); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Validate values provided to callback + const changedValues = subscriptionSpy.args[0][0]; + expect(changedValues).to.deep.equal(testValues); + }); + + it('does not execute onChange callback when isContextProvider is missing from subscription change message', async() => { + const testValues = { + testVal: 'testVal' + }; + + setUpIsFramedMessageListener(mockFrame, undefined, true); + + const subscriptionSpy = sandbox.spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeMessage(false, mockContextType, testValues); + await aTimeout(50); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + it('does not execute onChange callback when type is missing from subscription change message', async() => { + const testValues = { + testVal: 'testVal' + }; + + setUpIsFramedMessageListener(mockFrame, undefined, true); + + const subscriptionSpy = sandbox.spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeMessage(true, undefined, testValues); + await aTimeout(50); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + it('does not execute onChange callback when changed values are missing from subscription change message', async() => { + setUpIsFramedMessageListener(mockFrame, undefined, true); + + const subscriptionSpy = sandbox.spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeMessage(true, mockContextType, undefined); + await aTimeout(50); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + }); + + describe('tryPerform', () => { + + it('does not provide a return value if the host response includes one', async() => { + const testVal = 'testVal'; + const requestSpy = sandbox.spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, testVal); + + const val = await tryPerform(mockContextType, mockOpts); + expect(val).to.equal(undefined); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + }); + + }); + + describe('unframed client', () => { + + const sendSubscriptionChangeEvent = (type, changedValues) => { + document.dispatchEvent(new CustomEvent( + 'lms-context-change', { + detail: { + type: type, + changedValues: changedValues + } + } + )); + }; + + const setUpMockHostEventListener = (spy, returnVal) => { + const handleContextRequest = e => { + document.removeEventListener('lms-context-request', handleContextRequest); + if (spy) spy(e.detail); + e.detail.value = returnVal; + }; + + document.addEventListener('lms-context-request', handleContextRequest); + }; + + const assertOneTimeRequestEvent = (eventDetails, type, opts, subscribe) => { + expect(eventDetails.type).to.equal(type); + expect(eventDetails.options).to.deep.equal(opts); + expect(eventDetails.subscribe).to.equal(subscribe); + }; + + describe('tryGet', () => { + + it('returns requested data when provided by the host', async() => { + const testVal = 'testVal'; + const requestSpy = sandbox.spy(); + setUpMockHostEventListener(requestSpy, testVal); + + const val = await tryGet(mockContextType, mockOpts); + expect(val).to.equal(testVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + it('returns correct requested data when provided by the host on subsequent calls', async() => { + const firstTestVal = 'testVal'; + const secondTestVal = 'otherTestVal'; + + setUpMockHostEventListener(undefined, firstTestVal); + await tryGet(mockContextType, mockOpts); + + const requestSpy = sandbox.spy(); + setUpMockHostEventListener(requestSpy, secondTestVal); + + const secondVal = await tryGet(mockContextType, mockOpts); + expect(secondVal).to.equal(secondTestVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + it('does not send subscribe event if onChange callback is not a function', async() => { + const requestSpy = sandbox.spy(); + setUpMockHostEventListener(requestSpy, undefined); + + await tryGet(mockContextType, mockOpts, 'notAFunction'); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + it('executes onChange callback when valid subscription change event is received', async() => { + const testValues = { + testVal: 'testVal' + }; + + const requestSpy = sandbox.spy(); + setUpMockHostEventListener(requestSpy, undefined); + + const subscriptionSpy = sandbox.spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + // Validate subscription info sent with request + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, true); + + sendSubscriptionChangeEvent(mockContextType, testValues); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Validate values provided to callback + const changedValues = subscriptionSpy.args[0][0]; + expect(changedValues).to.deep.equal(testValues); + }); + + it('does not execute onChange callback when type is missing from subscription change event', async() => { + const testValues = { + testVal: 'testVal' + }; + + const subscriptionSpy = sandbox.spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeEvent(undefined, testValues); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + }); + + describe('tryPerform', () => { + + it('does not provide a return value if the host response includes one', async() => { + const testVal = 'testVal'; + const requestSpy = sandbox.spy(); + setUpMockHostEventListener(requestSpy, testVal); + + const val = await tryPerform(mockContextType, mockOpts); + expect(val).to.equal(undefined); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + }); + + }); + +}); diff --git a/test/host.test.js b/test/host.test.js new file mode 100644 index 0000000..993fa3e --- /dev/null +++ b/test/host.test.js @@ -0,0 +1,390 @@ +import { allowFrame, initialize, registerPlugin, reset } from '../src/host/host-internal.js'; +import { aTimeout, expect, fixture, html } from '@brightspace-ui/testing'; +import sinon from 'sinon'; + +const eventListenerType = 'lms-context-request'; +const messageListenerType = 'message'; + +const mockContextType = 'test-context'; +const otherMockContextType = 'other-test-context'; +const mockOpts = { test: 'test' }; + +async function sendFramedClientRequest(frame, isContextProvider, type, subscriptionMessageSpy) { + const message = { + isContextProvider: isContextProvider, + type: type, + options: mockOpts, + subscribe: !!subscriptionMessageSpy + }; + + return await new Promise(resolve => { + frame.contentWindow.addEventListener('message', e => { + if (subscriptionMessageSpy) frame.contentWindow.addEventListener('message', subscriptionMessageSpy, { once: true }); + resolve(e.data); + }, { once: true }); + + const script = frame.contentWindow.document.createElement('script'); + script.type = 'text/javascript'; + script.innerHTML = `window.parent.postMessage(${JSON.stringify(message)}, '*');`; + frame.contentWindow.document.head.appendChild(script); + }); +} + +function sendNonFramedClientRequest(type, subscriptionEventSpy) { + const eventDetails = { + type: type, + options: mockOpts, + subscribe: !!subscriptionEventSpy + }; + + if (subscriptionEventSpy) { + document.addEventListener('lms-context-change', e => subscriptionEventSpy(e.detail), { once: true }); + } + + const event = new CustomEvent( + 'lms-context-request', { + detail: eventDetails + } + ); + + document.dispatchEvent(event); + return event.detail.value; +} + +describe('lms-context-provider host', () => { + + const sandbox = sinon.createSandbox(); + + afterEach(() => { + reset(); + sandbox.restore(); + }); + + describe('initialization', () => { + + it('sets up appropriate event handlers when initialized', () => { + const docSpy = sandbox.spy(document, 'addEventListener'); + const windowSpy = sandbox.spy(window, 'addEventListener'); + + initialize(); + + expect(docSpy).to.have.been.calledOnce; + expect(docSpy).to.have.always.been.calledWithMatch(eventListenerType); + expect(docSpy.args[0]).to.have.length(2); + expect(docSpy.args[0][1]).to.be.a('function'); + + expect(windowSpy).to.have.been.calledOnce; + expect(windowSpy).to.have.always.been.calledWithMatch(messageListenerType); + expect(windowSpy.args[0]).to.have.length(2); + expect(windowSpy.args[0][1]).to.be.a('function'); + + }); + + it('does not throw on multiple initializations', () => { + initialize(); + expect(() => initialize()).not.to.throw; + }); + + }); + + describe('allowing frames', () => { + const frame = fixture(html``); + + it('throws when allowing a frame before initialization', () => { + expect(() => allowFrame(frame)).to.throw; + }); + + it('throws when attempting to allow a frame that has already been allowed', () => { + initialize(); + + allowFrame(frame); + expect(() => allowFrame(frame)).to.throw; + }); + + }); + + describe('registering plugins', () => { + + it('throws when host has not yet been initialized', () => { + expect(() => registerPlugin(mockContextType)).to.throw; + }); + + it('throws when trying to re-register an existing plugin', () => { + initialize(); + registerPlugin(mockContextType); + expect(() => registerPlugin(mockContextType)).to.throw; + }); + + it('does not throw when registering multiple different plugins', () => { + initialize(); + registerPlugin(mockContextType); + expect(() => registerPlugin(otherMockContextType)).not.to.throw; + }); + + }); + + describe('framed client', () => { + + const assertContextRequestMessageResponse = (messageData, expectedReturnVal, expectedType) => { + expect(messageData.isContextProvider).to.be.true; + expect(messageData.type).to.equal(expectedType || mockContextType); + expect(messageData.value).to.equal(expectedReturnVal); + }; + + const assertSubscriptionMessageResponse = (messageData, expectedReturnVal) => { + expect(messageData.isContextProvider).to.be.true; + expect(messageData.type).to.equal(mockContextType); + expect(messageData.changedValues).to.deep.equal(expectedReturnVal); + }; + + let mockFrame; + beforeEach(async() => { + initialize(); + mockFrame = await fixture(html``); + }); + + it('passes data through when a plugin can handle the request', async() => { + const testVal = 'testVal'; + const tryGetStub = sandbox.stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + allowFrame(mockFrame, window.location.origin); + + const messageData = await sendFramedClientRequest(mockFrame, true, mockContextType); + assertContextRequestMessageResponse(messageData, testVal); + }); + + it('returns undefined when no host plugin can handle the request', async() => { + allowFrame(mockFrame, window.location.origin); + const messageData = await sendFramedClientRequest(mockFrame, true, mockContextType); + assertContextRequestMessageResponse(messageData, undefined); + }); + + it('returns is-framed response when requested, regardless of registered plugins', async() => { + allowFrame(mockFrame, window.location.origin); + + const messageData = await sendFramedClientRequest(mockFrame, true, 'framed-request'); + assertContextRequestMessageResponse(messageData, true, 'framed-request'); + }); + + it('ignores requests without isContextProvider specified', async() => { + const testVal = 'testVal'; + const tryGetStub = sandbox.stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + allowFrame(mockFrame, window.location.origin); + + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, false, mockContextType), + aTimeout(50) + ]); + expect(messageData).to.be.undefined; + }); + + it('ignores requests without a type specified', async() => { + const testVal = 'testVal'; + const tryGetStub = sandbox.stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + allowFrame(mockFrame, window.location.origin); + + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, undefined), + aTimeout(50) + ]); + expect(messageData).to.be.undefined; + }); + + it('ignores requests from framed clients that have not been allowed', async() => { + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType), + aTimeout(50) + ]); + expect(messageData).to.be.undefined; + }); + + it('ignores requests from framed clients with an unexpected origin', async() => { + allowFrame(mockFrame, 'someFakeOrigin'); + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType), + aTimeout(50) + ]); + expect(messageData).to.be.undefined; + }); + + it('sends subscription events when a client has requested a subscription', async() => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + + const subscriptionSpy = sandbox.spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + allowFrame(mockFrame, window.location.origin); + + const subscriptionMessageSpy = sandbox.spy(); + await sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy); + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Trigger subscription callback in order to mimic a context change event + subscriptionSpy.args[0][0](testValues); + await aTimeout(50); + + expect(subscriptionMessageSpy).to.have.been.calledOnce; + expect(subscriptionMessageSpy.args[0]).to.have.length(1); + + // Assert subscription message response + const messageData = subscriptionMessageSpy.args[0][0].data; + assertSubscriptionMessageResponse(messageData, testValues); + }); + + it('sends an immediate subscription event when a plugin is registered and a subscription is queued', async() => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + allowFrame(mockFrame, window.location.origin); + + const subscriptionMessageSpy = sandbox.spy(); + await sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy); + + // Shouldn't receive a subscription message before a host plugin has been registered. + expect(subscriptionMessageSpy).not.to.have.been.called; + + // Register a plugin to handle this context request after it has originally been set. + const subscriptionSpy = sandbox.spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(2); + expect(subscriptionSpy.args[0][1]).to.deep.equal({ sendImmediate: true }); + + subscriptionSpy.args[0][0](testValues); + await aTimeout(50); + + expect(subscriptionMessageSpy).to.have.been.calledOnce; + expect(subscriptionMessageSpy.args[0]).to.have.length(1); + + // Assert subscription message response + const messageData = subscriptionMessageSpy.args[0][0].data; + assertSubscriptionMessageResponse(messageData, testValues); + }); + + it('does not send subscription events to framed clients that have not been allowed', async() => { + const subscriptionSpy = sandbox.spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + const subscriptionMessageSpy = sandbox.spy(); + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy), + aTimeout(50) + ]); + + expect(messageData).to.be.undefined; + expect(subscriptionSpy).not.to.have.been.called; + expect(subscriptionMessageSpy).not.to.have.been.called; + }); + + it('does not send subscription events to framed clients with an unexpected origin', async() => { + const subscriptionSpy = sandbox.spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + allowFrame(mockFrame, 'someFakeOrigin'); + + const subscriptionMessageSpy = sandbox.spy(); + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy), + aTimeout(50) + ]); + + expect(messageData).to.be.undefined; + expect(subscriptionSpy).not.to.have.been.called; + expect(subscriptionMessageSpy).not.to.have.been.called; + }); + + }); + + describe('unframed client', () => { + + beforeEach(() => { + initialize(); + }); + + it('passes data through when a plugin can handle the request', () => { + const testVal = 'testVal'; + const tryGetStub = sandbox.stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + + const val = sendNonFramedClientRequest(mockContextType); + expect(val).to.equal(testVal); + }); + + it('returns undefined when no host plugin can handle the request', () => { + const val = sendNonFramedClientRequest(mockContextType); + expect(val).to.be.undefined; + }); + + it('ignores requests without a type specified', async() => { + const testVal = 'testVal'; + const tryGetStub = sandbox.stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + + const val = sendNonFramedClientRequest(); + expect(val).to.be.undefined; + }); + + it('sends subscription events when a client has requested a subscription', () => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + + const subscriptionSpy = sandbox.spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + const subscriptionEventSpy = sandbox.spy(); + sendNonFramedClientRequest(mockContextType, subscriptionEventSpy); + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Trigger subscription callback in order to mimic a context change event + subscriptionSpy.args[0][0](testValues); + + expect(subscriptionEventSpy).to.have.been.calledOnce; + expect(subscriptionEventSpy.args[0]).to.have.length(1); + + // Assert subscription event response + const changedValues = subscriptionEventSpy.args[0][0].changedValues; + expect(changedValues).to.deep.equal(testValues); + }); + + it('sends an immediate subscription event when a plugin is registered and a subscription is queued', () => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + + const subscriptionEventSpy = sandbox.spy(); + sendNonFramedClientRequest(mockContextType, subscriptionEventSpy); + + // Shouldn't receive a subscription event before a host plugin has been registered. + expect(subscriptionEventSpy).not.to.have.been.called; + + // Register a plugin to handle this context request after it has originally been set. + const subscriptionSpy = sandbox.spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(2); + expect(subscriptionSpy.args[0][1]).to.deep.equal({ sendImmediate: true }); + + subscriptionSpy.args[0][0](testValues); + + expect(subscriptionEventSpy).to.have.been.calledOnce; + expect(subscriptionEventSpy.args[0]).to.have.length(1); + + // Assert subscription event response + const changedValues = subscriptionEventSpy.args[0][0].changedValues; + expect(changedValues).to.deep.equal(testValues); + }); + + }); + +}); From fafadfc60a1cfbcc166a119312151fccd67a1a23 Mon Sep 17 00:00:00 2001 From: Grant Cleary Date: Thu, 25 Apr 2024 12:11:38 -0400 Subject: [PATCH 2/6] Improve readability of README and code examples Co-authored-by: Dave Lockhart --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 60e89a6..be338eb 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ initialize(); #### Registering Plugins -To register a host plugin, import and execute the `registerPlugin` function on a page where a host has already been initialized. The provided context type should be unique per page. If your plugin needs to return data to a client, it should provide a `tryGetCallback` as the second argument. If it allows subscriptions to change events, then it should provide a `subscribeCallback` as the third argument. +To register a host plugin, import and execute the `registerPlugin` function on a page where a host has already been initialized. The provided context type should be unique per page. If your plugin needs to return data to a client, it should provide a `tryGetCallback` as the second argument. If clients can be notified when the data changes, then it should provide a `subscribeCallback` as the third argument. ```js import { registerPlugin } from '@d2l/lms-context/provider/host.js'; @@ -56,7 +56,7 @@ registerPlugin('my-context-type', tryGetCallback, subscribeCallback); #### Framed Clients -When working with a client inside an iframe, the host page needs to explicitly allow that iframe. To do this, import and execute `allowFrame` from the host page. The host must already be initialized. The first argument must be the iframe element itself. The second argument should be the expected origin. Requests from clients within iframes that have not explicitly bene allowed or that come from a different origin will be ignored. +When working with a client inside an iframe, the host page needs to explicitly allow that iframe. To do this, import and execute `allowFrame` from the host page. The host must already be initialized. The first argument must be the iframe element itself. The second argument should be the expected origin. Requests from clients within iframes that have not explicitly been allowed or that come from a different origin will be ignored. ```js import { allowFrame } from '@d2l/lms-context-provider/host.js'; @@ -76,14 +76,17 @@ When your library or component is expecting data to be returned, import `tryGet` ```js import { tryGet } from '@d2l/lms-context-provider/client.js'; -// This callback should accept a single argument, an object containing any relevant information from the host plugin. -function onChangeCallback(changedValues) { +const val = await tryGet( + 'my-context-type', + { someProp: someVal }, + (changedValues) => { + // This callback should accept a single argument: + // an object containing any relevant information from the host plugin if (changedValues.someChangedProp === 'someVal') { doSomeWork(changedValues.someChangedProp); } -} - -const val = await tryGet('my-context-type', { someProp: someVal }, onChangeCallback); + } +); doSomeWork(val); ``` From 4e991f254254b4b26b931224d9ee623e15a635ae Mon Sep 17 00:00:00 2001 From: Grant Cleary Date: Thu, 25 Apr 2024 12:20:58 -0400 Subject: [PATCH 3/6] Simplify some object initializers and type checks Co-authored-by: Dave Lockhart --- src/client/client-internal.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/client/client-internal.js b/src/client/client-internal.js index 2bcf43a..5174d98 100644 --- a/src/client/client-internal.js +++ b/src/client/client-internal.js @@ -56,9 +56,9 @@ async function sendEvent(type, options, subscribe) { const message = { isContextProvider: true, - type: type, - options: options, - subscribe: subscribe + type, + options, + subscribe }; return await Promise.race([ @@ -73,11 +73,7 @@ async function sendEvent(type, options, subscribe) { const event = new CustomEvent( 'lms-context-request', { - detail: { - type: type, - options: options, - subscribe: subscribe - } + detail: { type, options, subscribe } } ); @@ -109,7 +105,7 @@ export async function isFramed() { } export async function tryGet(contextType, options, onChangeCallback) { - const subscribe = (onChangeCallback && typeof(onChangeCallback) === 'function') || false; + const subscribe = (typeof(onChangeCallback) === 'function') || false; // Send one-time request first to make sure it's responded to before any change listeners are registered. const value = await sendEvent(contextType, options, subscribe); From 7b8358dd3e3d2f40533214af5bb52bbddc73d3ad Mon Sep 17 00:00:00 2001 From: Grant Cleary Date: Thu, 25 Apr 2024 17:23:05 -0400 Subject: [PATCH 4/6] Reject client promises when host doesn't respond --- package-lock.json | 457 ++++++++++++++-------------------- package.json | 4 +- src/client/client-internal.js | 35 +-- src/error.js | 6 + src/host/host-internal.js | 17 +- test/client.test.js | 263 ++++++++++++------- test/host.test.js | 168 ++++++------- 7 files changed, 489 insertions(+), 461 deletions(-) create mode 100644 src/error.js diff --git a/package-lock.json b/package-lock.json index eaad68d..2d4b5ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,7 @@ "@brightspace-ui/testing": "^1", "eslint": "^8", "eslint-config-brightspace": "^1", - "sinon": "^17", - "sinon-chai": "3" + "sinon": "^17" } }, "node_modules/@75lb/deep-merge": { @@ -75,9 +74,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.1.tgz", - "integrity": "sha512-Pc65opHDliVpRHuKfzI+gSA4zcgr65O4cl64fFJIWEEh8JoHIHh0Oez1Eo8Arz8zq/JhgKodQaxEwUPRtZylVA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", "dev": true, "peer": true, "engines": { @@ -85,19 +84,19 @@ } }, "node_modules/@babel/core": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.3.tgz", - "integrity": "sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", "dev": true, "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.1", + "@babel/generator": "^7.24.4", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.1", - "@babel/parser": "^7.24.1", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", "@babel/template": "^7.24.0", "@babel/traverse": "^7.24.1", "@babel/types": "^7.24.0", @@ -135,9 +134,9 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.1.tgz", - "integrity": "sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", "dev": true, "peer": true, "dependencies": { @@ -293,9 +292,9 @@ } }, "node_modules/@babel/helpers": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.1.tgz", - "integrity": "sha512-BpU09QqEe6ZCHuIHFphEFgvNSrubve1FtyMton26ekZ85gRGi6LrTF7zArARp2YvyFxloeiRmtSCq5sjh1WqIg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", "dev": true, "peer": true, "dependencies": { @@ -323,9 +322,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", - "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", "dev": true, "peer": true, "bin": { @@ -747,9 +746,9 @@ } }, "node_modules/@open-wc/scoped-elements/node_modules/lit": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.1.2.tgz", - "integrity": "sha512-VZx5iAyMtX7CV4K8iTLdCkMaYZ7ipjJZ0JcSdJ0zIdGxxyurjIn7yuuSxNBD7QmjvcNJwr0JS4cAdAtsy7gZ6w==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.1.3.tgz", + "integrity": "sha512-l4slfspEsnCcHVRTvaP7YnkTZEZggNFywLEIhQaGhYDczG+tu/vlgm/KaWIEjIp+ZyV20r2JnZctMb8LeLCG7Q==", "dev": true, "dependencies": { "@lit/reactive-element": "^2.0.4", @@ -758,9 +757,9 @@ } }, "node_modules/@open-wc/scoped-elements/node_modules/lit-element": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.4.tgz", - "integrity": "sha512-98CvgulX6eCPs6TyAIQoJZBCQPo80rgXR+dVBs61cstJXqtI+USQZAbA4gFHh6L/mxBx9MrgPLHLsUgDUHAcCQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.5.tgz", + "integrity": "sha512-iTWskWZEtn9SyEf4aBG6rKT8GABZMrTWop1+jopsEOgEcugcXJGKuX5bEbkq9qfzY+XB4MAgCaSPwnNpdsNQ3Q==", "dev": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.2.0", @@ -793,9 +792,9 @@ } }, "node_modules/@open-wc/testing-helpers": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@open-wc/testing-helpers/-/testing-helpers-3.0.0.tgz", - "integrity": "sha512-zkR39b7ljH/TqZgzBB9ekHKg1OLvR/JQYCEaW76V0RuASfV/vkgx2xfUQNe8DlEOLOetRZ3agFqssEREF45ClA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@open-wc/testing-helpers/-/testing-helpers-3.0.1.tgz", + "integrity": "sha512-hyNysSatbgT2FNxHJsS3rGKcLEo6+HwDFu1UQL6jcSQUabp/tj3PyX7UnXL3H5YGv0lJArdYLSnvjLnjn3O2fw==", "dev": true, "dependencies": { "@open-wc/scoped-elements": "^3.0.2", @@ -814,9 +813,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.0.tgz", - "integrity": "sha512-MC7LxpcBtdfTbzwARXIkqGZ1Osn3nnZJlm+i0+VqHl72t//Xwl9wICrXT8BwtgC6s1xJNHsxOpvzISUqe92+sw==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.3.tgz", + "integrity": "sha512-bJ0UBsk0ESOs6RFcLXOt99a3yTDcOKlzfjad+rhFwdaG1Lu/Wzq58GHYCDTlZ9z6mldf4g+NTb+TXEfe0PpnsQ==", "dev": true, "dependencies": { "debug": "4.3.4", @@ -916,9 +915,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.2.tgz", - "integrity": "sha512-3XFIDKWMFZrMnao1mJhnOT1h2g0169Os848NhhmGweEcfJ4rCi+3yMCOLG4zA61rbJdkcrM/DjVZm9Hg5p5w7g==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.16.4.tgz", + "integrity": "sha512-GkhjAaQ8oUTOKE4g4gsZ0u8K/IHU1+2WQSgS1TwTcYvL+sjbaQjNHFXbOJ6kgqGHIO1DfUhI/Sphi9GkRT9K+Q==", "cpu": [ "arm" ], @@ -929,9 +928,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.2.tgz", - "integrity": "sha512-GdxxXbAuM7Y/YQM9/TwwP+L0omeE/lJAR1J+olu36c3LqqZEBdsIWeQ91KBe6nxwOnb06Xh7JS2U5ooWU5/LgQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.16.4.tgz", + "integrity": "sha512-Bvm6D+NPbGMQOcxvS1zUl8H7DWlywSXsphAeOnVeiZLQ+0J6Is8T7SrjGTH29KtYkiY9vld8ZnpV3G2EPbom+w==", "cpu": [ "arm64" ], @@ -942,9 +941,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.2.tgz", - "integrity": "sha512-mCMlpzlBgOTdaFs83I4XRr8wNPveJiJX1RLfv4hggyIVhfB5mJfN4P8Z6yKh+oE4Luz+qq1P3kVdWrCKcMYrrA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.16.4.tgz", + "integrity": "sha512-i5d64MlnYBO9EkCOGe5vPR/EeDwjnKOGGdd7zKFhU5y8haKhQZTN2DgVtpODDMxUr4t2K90wTUJg7ilgND6bXw==", "cpu": [ "arm64" ], @@ -955,9 +954,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.2.tgz", - "integrity": "sha512-yUoEvnH0FBef/NbB1u6d3HNGyruAKnN74LrPAfDQL3O32e3k3OSfLrPgSJmgb3PJrBZWfPyt6m4ZhAFa2nZp2A==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.16.4.tgz", + "integrity": "sha512-WZupV1+CdUYehaZqjaFTClJI72fjJEgTXdf4NbW69I9XyvdmztUExBtcI2yIIU6hJtYvtwS6pkTkHJz+k08mAQ==", "cpu": [ "x64" ], @@ -968,9 +967,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.2.tgz", - "integrity": "sha512-GYbLs5ErswU/Xs7aGXqzc3RrdEjKdmoCrgzhJWyFL0r5fL3qd1NPcDKDowDnmcoSiGJeU68/Vy+OMUluRxPiLQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.16.4.tgz", + "integrity": "sha512-ADm/xt86JUnmAfA9mBqFcRp//RVRt1ohGOYF6yL+IFCYqOBNwy5lbEK05xTsEoJq+/tJzg8ICUtS82WinJRuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.16.4.tgz", + "integrity": "sha512-tJfJaXPiFAG+Jn3cutp7mCs1ePltuAgRqdDZrzb1aeE3TktWWJ+g7xK9SNlaSUFw6IU4QgOxAY4rA+wZUT5Wfg==", "cpu": [ "arm" ], @@ -981,9 +993,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.2.tgz", - "integrity": "sha512-L1+D8/wqGnKQIlh4Zre9i4R4b4noxzH5DDciyahX4oOz62CphY7WDWqJoQ66zNR4oScLNOqQJfNSIAe/6TPUmQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.16.4.tgz", + "integrity": "sha512-7dy1BzQkgYlUTapDTvK997cgi0Orh5Iu7JlZVBy1MBURk7/HSbHkzRnXZa19ozy+wwD8/SlpJnOOckuNZtJR9w==", "cpu": [ "arm64" ], @@ -994,9 +1006,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.2.tgz", - "integrity": "sha512-tK5eoKFkXdz6vjfkSTCupUzCo40xueTOiOO6PeEIadlNBkadH1wNOH8ILCPIl8by/Gmb5AGAeQOFeLev7iZDOA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.16.4.tgz", + "integrity": "sha512-zsFwdUw5XLD1gQe0aoU2HVceI6NEW7q7m05wA46eUAyrkeNYExObfRFQcvA6zw8lfRc5BHtan3tBpo+kqEOxmg==", "cpu": [ "arm64" ], @@ -1007,11 +1019,11 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.13.2.tgz", - "integrity": "sha512-zvXvAUGGEYi6tYhcDmb9wlOckVbuD+7z3mzInCSTACJ4DQrdSLPNUeDIcAQW39M3q6PDquqLWu7pnO39uSMRzQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.16.4.tgz", + "integrity": "sha512-p8C3NnxXooRdNrdv6dBmRTddEapfESEUflpICDNKXpHvTjRRq1J82CbU5G3XfebIZyI3B0s074JHMWD36qOW6w==", "cpu": [ - "ppc64le" + "ppc64" ], "dev": true, "optional": true, @@ -1020,9 +1032,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.2.tgz", - "integrity": "sha512-C3GSKvMtdudHCN5HdmAMSRYR2kkhgdOfye4w0xzyii7lebVr4riCgmM6lRiSCnJn2w1Xz7ZZzHKuLrjx5620kw==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.16.4.tgz", + "integrity": "sha512-Lh/8ckoar4s4Id2foY7jNgitTOUQczwMWNYi+Mjt0eQ9LKhr6sK477REqQkmy8YHY3Ca3A2JJVdXnfb3Rrwkng==", "cpu": [ "riscv64" ], @@ -1033,9 +1045,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.13.2.tgz", - "integrity": "sha512-l4U0KDFwzD36j7HdfJ5/TveEQ1fUTjFFQP5qIt9gBqBgu1G8/kCaq5Ok05kd5TG9F8Lltf3MoYsUMw3rNlJ0Yg==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.16.4.tgz", + "integrity": "sha512-1xwwn9ZCQYuqGmulGsTZoKrrn0z2fAur2ujE60QgyDpHmBbXbxLaQiEvzJWDrscRq43c8DnuHx3QorhMTZgisQ==", "cpu": [ "s390x" ], @@ -1046,9 +1058,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.2.tgz", - "integrity": "sha512-xXMLUAMzrtsvh3cZ448vbXqlUa7ZL8z0MwHp63K2IIID2+DeP5iWIT6g1SN7hg1VxPzqx0xZdiDM9l4n9LRU1A==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.16.4.tgz", + "integrity": "sha512-LuOGGKAJ7dfRtxVnO1i3qWc6N9sh0Em/8aZ3CezixSTM+E9Oq3OvTsvC4sm6wWjzpsIlOCnZjdluINKESflJLA==", "cpu": [ "x64" ], @@ -1059,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.2.tgz", - "integrity": "sha512-M/JYAWickafUijWPai4ehrjzVPKRCyDb1SLuO+ZyPfoXgeCEAlgPkNXewFZx0zcnoIe3ay4UjXIMdXQXOZXWqA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.16.4.tgz", + "integrity": "sha512-ch86i7KkJKkLybDP2AtySFTRi5fM3KXp0PnHocHuJMdZwu7BuyIKi35BE9guMlmTpwwBTB3ljHj9IQXnTCD0vA==", "cpu": [ "x64" ], @@ -1072,9 +1084,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.2.tgz", - "integrity": "sha512-2YWwoVg9KRkIKaXSh0mz3NmfurpmYoBBTAXA9qt7VXk0Xy12PoOP40EFuau+ajgALbbhi4uTj3tSG3tVseCjuA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.16.4.tgz", + "integrity": "sha512-Ma4PwyLfOWZWayfEsNQzTDBVW8PZ6TUUN1uFTBQbF2Chv/+sjenE86lpiEwj2FiviSmSZ4Ap4MaAfl1ciF4aSA==", "cpu": [ "arm64" ], @@ -1085,9 +1097,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.2.tgz", - "integrity": "sha512-2FSsE9aQ6OWD20E498NYKEQLneShWes0NGMPQwxWOdws35qQXH+FplabOSP5zEe1pVjurSDOGEVCE2agFwSEsw==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.16.4.tgz", + "integrity": "sha512-9m/ZDrQsdo/c06uOlP3W9G2ENRVzgzbSXmXHT4hwVaDQhYcRpi9bgBT0FTG9OhESxwK0WjQxYOSfv40cU+T69w==", "cpu": [ "ia32" ], @@ -1098,9 +1110,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.2.tgz", - "integrity": "sha512-7h7J2nokcdPePdKykd8wtc8QqqkqxIrUz7MHj6aNr8waBRU//NLDVnNjQnqQO6fqtjrtCdftpbTuOKAyrAQETQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.16.4.tgz", + "integrity": "sha512-YunpoOAyGLDseanENHmbFvQSfVL5BxW3k7hhy0eN4rb3gS/ct75dVD0EXOWIqFT/nE8XYW6LP6vz6ctKRi0k9A==", "cpu": [ "x64" ], @@ -1274,9 +1286,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.43", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", - "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", "dev": true, "dependencies": { "@types/node": "*", @@ -1366,9 +1378,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.2.tgz", - "integrity": "sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==", + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -1381,9 +1393,9 @@ "dev": true }, "node_modules/@types/qs": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", "dev": true }, "node_modules/@types/range-parser": { @@ -1409,14 +1421,14 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "dev": true, "dependencies": { "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" + "@types/node": "*", + "@types/send": "*" } }, "node_modules/@types/sinon": { @@ -1497,9 +1509,9 @@ } }, "node_modules/@web/dev-server": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@web/dev-server/-/dev-server-0.4.3.tgz", - "integrity": "sha512-vf2ZVjdTj8ExrMSYagyHD+snRue9oRetynxd1p0P7ndEpZDKeNLYsvkJyo0pNU6moBxHmXnYeC5VrAT4E3+lNg==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@web/dev-server/-/dev-server-0.4.4.tgz", + "integrity": "sha512-Gye0DhDbst/KVNRCFzRd+4V9LJmuuQYJBsf6UXeEbCYuBSKeshEW4AA1esLsfy1gONsD6NIGiru5509l35P9Ug==", "dev": true, "dependencies": { "@babel/code-frame": "^7.12.11", @@ -2013,16 +2025,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "peer": true, - "engines": { - "node": "*" - } - }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2107,35 +2109,44 @@ "optional": true }, "node_modules/bare-fs": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.2.2.tgz", - "integrity": "sha512-X9IqgvyB0/VA5OZJyb5ZstoN62AzD7YxVGog13kkfYWYqJYcK0kcqLZ6TrmH5qr4/8//ejVcX4x/a0UvaogXmA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.0.tgz", + "integrity": "sha512-TNFqa1B4N99pds2a5NYHR15o0ZpdNKbAeKTE/+G6ED/UeOavv8RY3dr/Fu99HW3zU3pXpo2kDNO8Sjsm2esfOw==", "dev": true, "optional": true, "dependencies": { "bare-events": "^2.0.0", - "bare-os": "^2.0.0", "bare-path": "^2.0.0", - "streamx": "^2.13.0" + "bare-stream": "^1.0.0" } }, "node_modules/bare-os": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.2.1.tgz", - "integrity": "sha512-OwPyHgBBMkhC29Hl3O4/YfxW9n7mdTr2+SsO29XBWKKJsbgj3mnorDB80r5TiCQgQstgE5ga1qNYrpes6NvX2w==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.3.0.tgz", + "integrity": "sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==", "dev": true, "optional": true }, "node_modules/bare-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.0.tgz", - "integrity": "sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.2.tgz", + "integrity": "sha512-o7KSt4prEphWUHa3QUwCxUI00R86VdjiuxmJK0iNVDHYPGo+HsDaVCnqCmPbf/MiW1ok8F4p3m8RTHlWk8K2ig==", "dev": true, "optional": true, "dependencies": { "bare-os": "^2.1.0" } }, + "node_modules/bare-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-1.0.0.tgz", + "integrity": "sha512-KhNUoDL40iP4gFaLSsoGE479t0jHijfYdIcxRn/XtezA2BaUD0NRf/JGRpsMq6dMNM+SrCrB0YSSo/5wBY4rOQ==", + "dev": true, + "optional": true, + "dependencies": { + "streamx": "^2.16.1" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2356,9 +2367,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001605", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001605.tgz", - "integrity": "sha512-nXwGlFWo34uliI9z3n6Qc0wZaf7zaZWA1CPZ169La5mV3I/gem7bst0vr5XQH5TJXZIMfDeZyOrZnSlVzKxxHQ==", + "version": "1.0.30001612", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz", + "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==", "dev": true, "funding": [ { @@ -2376,25 +2387,6 @@ ], "peer": true }, - "node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", - "dev": true, - "peer": true, - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.0.8" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/chai-a11y-axe": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/chai-a11y-axe/-/chai-a11y-axe-1.5.0.tgz", @@ -2503,19 +2495,6 @@ "node": ">=8" } }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "peer": true, - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2571,9 +2550,9 @@ } }, "node_modules/chromium-bidi": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.16.tgz", - "integrity": "sha512-IT5lnR44h/qZQ4GaCHvBxYIl4cQL2i9UvFyYeRyVdcpY04hx5H720HQfe/7Oz7ndxaYVLQFGpCO71J4X2Ye/Gw==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.19.tgz", + "integrity": "sha512-UA6zL77b7RYCjJkZBsZ0wlvCTD+jTjllZ8f6wdO4buevXgTZYjV+XLB9CiEa2OuuTGGTLnI7eN9I60YxuALGQg==", "dev": true, "dependencies": { "mitt": "3.0.1", @@ -2950,19 +2929,6 @@ } } }, - "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", - "dev": true, - "peer": true, - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -3089,9 +3055,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1262051", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1262051.tgz", - "integrity": "sha512-YJe4CT5SA8on3Spa+UDtNhEqtuV6Epwz3OZ4HQVLhlRccpZ9/PAYk0/cy/oKxFKRrZPBUPyxympQci4yWNWZ9g==", + "version": "0.0.1273771", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1273771.tgz", + "integrity": "sha512-QDbb27xcTVReQQW/GHJsdQqGKwYBE7re7gxehj467kKP2DKuYBUj6i2k5LRiAC66J1yZG/9gsxooz/s9pcm0Og==", "dev": true }, "node_modules/diff": { @@ -3209,9 +3175,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.723", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.723.tgz", - "integrity": "sha512-rxFVtrMGMFROr4qqU6n95rUi9IlfIm+lIAt+hOToy/9r6CDv0XiEcQdC3VP71y1pE5CFTzKV0RvxOGYCPWWHPw==", + "version": "1.4.749", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.749.tgz", + "integrity": "sha512-LRMMrM9ITOvue0PoBrvNIraVmuDbJV5QC9ierz/z5VilMdPOVMjOtpICNld3PuXuTZ3CHH/UPxX9gHhAPwi+0Q==", "dev": true, "peer": true }, @@ -4271,16 +4237,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "peer": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -5360,9 +5316,9 @@ } }, "node_modules/koa": { - "version": "2.15.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.2.tgz", - "integrity": "sha512-MXTeZH3M6AJ8ukW2QZ8wqO3Dcdfh2WRRmjCBkEP+NhKNCiqlO5RDqHmSnsyNrbRJrdjyvIGSJho4vQiWgQJSVA==", + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", + "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", "dev": true, "dependencies": { "accepts": "^1.3.5", @@ -5734,9 +5690,9 @@ } }, "node_modules/lit-html": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.2.tgz", - "integrity": "sha512-3OBZSUrPnAHoKJ9AMjRL/m01YJxQMf+TMHanNtTHG68ubjnZxK0RFl102DPzsw4mWnHibfZIBJm3LWCZ/LmMvg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.3.tgz", + "integrity": "sha512-FwIbqDD8O/8lM4vUZ4KvQZjPPNx7V1VhT7vmRB8RBAO0AU6wuTVdoXiu2CivVjEGdugvcbPNBLtPE1y0ifplHA==", "dev": true, "dependencies": { "@types/trusted-types": "^2.0.2" @@ -5881,16 +5837,6 @@ "node": ">=8" } }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "peer": true, - "dependencies": { - "get-func-name": "^2.0.1" - } - }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -6141,9 +6087,9 @@ } }, "node_modules/nise/node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", "dev": true }, "node_modules/no-case": { @@ -6516,9 +6462,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.1.tgz", + "integrity": "sha512-tS24spDe/zXhWbNPErCHs/AGOzbKGHT+ybSBqmdLm8WZ1xXLWvH8Qn71QPAlqVhd0qUTWjy+Kl9JmISgDdEjsA==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -6542,16 +6488,6 @@ "node": ">=8" } }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "peer": true, - "engines": { - "node": "*" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -6598,12 +6534,12 @@ } }, "node_modules/playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "dev": true, "dependencies": { - "playwright-core": "1.42.1" + "playwright-core": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -6616,9 +6552,9 @@ } }, "node_modules/playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -6767,15 +6703,15 @@ } }, "node_modules/puppeteer-core": { - "version": "22.6.2", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.6.2.tgz", - "integrity": "sha512-Sws/9V2/7nFrn3MSsRPHn1pXJMIFn6FWHhoMFMUBXQwVvcBstRIa9yW8sFfxePzb56W1xNfSYzPRnyAd0+qRVQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.7.1.tgz", + "integrity": "sha512-jD7T7yN7PWGuJmNT0TAEboA26s0VVnvbgCxqgQIF+eNQW2u71ENaV2JwzSJiCHO+e72H4Ue6AgKD9USQ8xAcOQ==", "dev": true, "dependencies": { - "@puppeteer/browsers": "2.2.0", - "chromium-bidi": "0.5.16", + "@puppeteer/browsers": "2.2.3", + "chromium-bidi": "0.5.19", "debug": "4.3.4", - "devtools-protocol": "0.0.1262051", + "devtools-protocol": "0.0.1273771", "ws": "8.16.0" }, "engines": { @@ -6804,9 +6740,9 @@ } }, "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", + "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", "dev": true, "dependencies": { "side-channel": "^1.0.6" @@ -7083,9 +7019,9 @@ } }, "node_modules/rollup": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.2.tgz", - "integrity": "sha512-MIlLgsdMprDBXC+4hsPgzWUasLO9CE4zOkj/u6j+Z6j5A4zRY+CtiXAdJyPtgCsc42g658Aeh1DlrdVEJhsL2g==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.4.tgz", + "integrity": "sha512-kuaTJSUbz+Wsb2ATGvEknkI12XV40vIiHmLuFlejoo7HtDok/O5eDDD0UpCVY5bBX5U5RYo8wWP83H7ZsqVEnA==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -7098,21 +7034,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.2", - "@rollup/rollup-android-arm64": "4.13.2", - "@rollup/rollup-darwin-arm64": "4.13.2", - "@rollup/rollup-darwin-x64": "4.13.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.2", - "@rollup/rollup-linux-arm64-gnu": "4.13.2", - "@rollup/rollup-linux-arm64-musl": "4.13.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.13.2", - "@rollup/rollup-linux-riscv64-gnu": "4.13.2", - "@rollup/rollup-linux-s390x-gnu": "4.13.2", - "@rollup/rollup-linux-x64-gnu": "4.13.2", - "@rollup/rollup-linux-x64-musl": "4.13.2", - "@rollup/rollup-win32-arm64-msvc": "4.13.2", - "@rollup/rollup-win32-ia32-msvc": "4.13.2", - "@rollup/rollup-win32-x64-msvc": "4.13.2", + "@rollup/rollup-android-arm-eabi": "4.16.4", + "@rollup/rollup-android-arm64": "4.16.4", + "@rollup/rollup-darwin-arm64": "4.16.4", + "@rollup/rollup-darwin-x64": "4.16.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.16.4", + "@rollup/rollup-linux-arm-musleabihf": "4.16.4", + "@rollup/rollup-linux-arm64-gnu": "4.16.4", + "@rollup/rollup-linux-arm64-musl": "4.16.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.16.4", + "@rollup/rollup-linux-riscv64-gnu": "4.16.4", + "@rollup/rollup-linux-s390x-gnu": "4.16.4", + "@rollup/rollup-linux-x64-gnu": "4.16.4", + "@rollup/rollup-linux-x64-musl": "4.16.4", + "@rollup/rollup-win32-arm64-msvc": "4.16.4", + "@rollup/rollup-win32-ia32-msvc": "4.16.4", + "@rollup/rollup-win32-x64-msvc": "4.16.4", "fsevents": "~2.3.2" } }, @@ -7327,16 +7264,6 @@ "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon-chai": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", - "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", - "dev": true, - "peerDependencies": { - "chai": "^4.0.0", - "sinon": ">=4.0.0" - } - }, "node_modules/sinon/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7428,9 +7355,9 @@ } }, "node_modules/socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "dependencies": { "ip-address": "^9.0.5", @@ -7773,9 +7700,9 @@ } }, "node_modules/terser": { - "version": "5.30.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.2.tgz", - "integrity": "sha512-vTDjRKYKip4dOFL5VizdoxHTYDfEXPdz5t+FbxCC5Rp2s+KbEO8w5wqMDPgj7CtFKZuzq7PXv28fZoXfqqBVuw==", + "version": "5.30.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz", + "integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", diff --git a/package.json b/package.json index 99a6d56..971ab0e 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,6 @@ "./client.js": "./src/client/client.js" }, "files": [ - "/src", - "!demo", - "!test" + "/src" ] } diff --git a/src/client/client-internal.js b/src/client/client-internal.js index 5174d98..7a53b6d 100644 --- a/src/client/client-internal.js +++ b/src/client/client-internal.js @@ -1,3 +1,5 @@ +import { LmsContextProviderError } from '../error.js'; + const messageTimeoutMs = 75; let oneTimeMessageListenerInitialized = false; let subscriptionListenerInitialized = false; @@ -48,12 +50,17 @@ function handleSubscribedChangeResponse(responseData) { } async function sendEvent(type, options, subscribe) { - if (await isFramed()) { - if (subscribe && !subscriptionListenerInitialized) { - window.addEventListener('message', handleSubscribedChangeResponseMessage); - subscriptionListenerInitialized = true; - } + const isframedVal = await isFramed(); + if (subscribe && !subscriptionListenerInitialized) { + isframedVal + ? window.addEventListener('message', handleSubscribedChangeResponseMessage) + : document.addEventListener('lms-context-change', handleSubscribedChangeResponseEvent); + + subscriptionListenerInitialized = true; + } + + if (isframedVal) { const message = { isContextProvider: true, type, @@ -63,14 +70,14 @@ async function sendEvent(type, options, subscribe) { return await Promise.race([ sendMessage(message), - new Promise(resolve => setTimeout(() => resolve(undefined), messageTimeoutMs)) + new Promise((_, reject) => + setTimeout( + () => reject(new LmsContextProviderError('No response from host')), + messageTimeoutMs + ) + ) ]); } else { - if (subscribe && !subscriptionListenerInitialized) { - document.addEventListener('lms-context-change', handleSubscribedChangeResponseEvent); - subscriptionListenerInitialized = true; - } - const event = new CustomEvent( 'lms-context-request', { detail: { type, options, subscribe } @@ -78,11 +85,12 @@ async function sendEvent(type, options, subscribe) { ); document.dispatchEvent(event); - return event.detail.value; + return event.detail.handled + ? event.detail.value + : Promise.reject(new LmsContextProviderError('No response from host')); } } -// DO NOT IMPORT! Usage should be internal only; this is exported only for testing purposes. export async function isFramed() { if (framed !== undefined) return framed; @@ -124,7 +132,6 @@ export async function tryPerform(actionType, options) { await sendEvent(actionType, options, false); } -// DO NOT IMPORT! Used for testing only! export function reset() { window.removeEventListener('message', handleOneTimeMessageResponse); window.removeEventListener('message', handleSubscribedChangeResponseMessage); diff --git a/src/error.js b/src/error.js new file mode 100644 index 0000000..f6114d5 --- /dev/null +++ b/src/error.js @@ -0,0 +1,6 @@ +export class LmsContextProviderError extends Error { + constructor(message) { + super(`lms-context-provider: ${message}`); + this.name = 'LmsContextProviderError'; + } +} diff --git a/src/host/host-internal.js b/src/host/host-internal.js index 3a6144f..2c18d92 100644 --- a/src/host/host-internal.js +++ b/src/host/host-internal.js @@ -1,3 +1,5 @@ +import { LmsContextProviderError } from '../error.js'; + let initialized = false; const allowedFrames = new Map(); @@ -18,10 +20,14 @@ function handleContextRequest(type, options, subscribe) { function handleContextRequestEvent(e) { if (!e.detail || !e.detail.type) return; e.detail.value = handleContextRequest(e.detail.type, e.detail.options, e.detail.subscribe); + e.detail.handled = true; } function handleContextRequestMessage(e) { - if (!e.data.isContextProvider || !e.data.type || !/^(?:http|https):\/\//.test(e.origin)) return; + if (!e.data.isContextProvider) return; + if (!e.data.type || !/^(?:http|https):\/\//.test(e.origin)) { + throw new LmsContextProviderError(`Invalid message sent by framed client at origin ${e.origin}`); + } let targetFrame; for (const frame of allowedFrames.keys()) { @@ -71,11 +77,11 @@ export function initialize() { export function allowFrame(frame, origin) { if (!initialized) { - throw new Error(`lms-context-provider: Can't register frame with id ${frame.id}. Context provider host has not been initialized.`); + throw new LmsContextProviderError(`Can't register frame with id ${frame.id}. Context provider host has not been initialized.`); } if (allowedFrames.has(frame)) { - throw new Error(`lms-context-provider: A frame with id ${frame.id} has already been registered with this host.`); + throw new LmsContextProviderError(`A frame with id ${frame.id} has already been registered with this host.`); } allowedFrames.set(frame, origin); @@ -83,11 +89,11 @@ export function allowFrame(frame, origin) { export function registerPlugin(type, tryGetCallback, subscriptionCallback) { if (!initialized) { - throw new Error(`lms-context-provider: Can't register plugin with type ${type}. Context provider host has not been initialized.`); + throw new LmsContextProviderError(`Can't register plugin with type ${type}. Context provider host has not been initialized.`); } if (registeredPlugins.has(type)) { - throw new Error(`lms-context-provider: A plugin with type ${type} has already been registered with this host.`); + throw new LmsContextProviderError(`A plugin with type ${type} has already been registered with this host.`); } registeredPlugins.set(type, { @@ -101,7 +107,6 @@ export function registerPlugin(type, tryGetCallback, subscriptionCallback) { } } -// DO NOT IMPORT! Used for testing only! export function reset() { if (!initialized) return; diff --git a/test/client.test.js b/test/client.test.js index 750fa5e..74684c8 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -1,6 +1,7 @@ import { aTimeout, expect, fixture, html } from '@brightspace-ui/testing'; import { isFramed, reset, tryGet, tryPerform } from '../src/client/client-internal.js'; -import sinon from 'sinon'; +import { restore, spy, stub, useFakeTimers } from 'sinon'; +import { LmsContextProviderError } from '../src/error.js'; const mockContextType = 'test-context'; const mockOpts = { test: 'test' }; @@ -14,26 +15,33 @@ function assertFramedOneTimeRequestMessage(messageData, type, opts, subscribe) { describe('lms-context-provider client', () => { - const sandbox = sinon.createSandbox(); + let clock; + beforeEach(() => { + clock = useFakeTimers({ + now: Date.now(), + shouldAdvanceTime: true + }); + }); afterEach(() => { reset(); - sandbox.restore(); + clock.restore(); + restore(); }); - const setUpIsFramedMessageListener = (frame, spy, respond) => { - const handleIsFramedMessage = e => { - frame.contentWindow.removeEventListener('message', handleIsFramedMessage); - if (spy) spy(e.data); - if (!respond) return; + describe('is framed', () => { - window.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, '*'); - }; + const setUpIsFramedMessageListener = (frame, spy, respond) => { + const handleIsFramedMessage = e => { + frame.contentWindow.removeEventListener('message', handleIsFramedMessage); + if (spy) spy(e.data); + if (!respond) return; - frame.contentWindow.addEventListener('message', handleIsFramedMessage); - }; + window.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, '*'); + }; - describe('is framed', () => { + frame.contentWindow.addEventListener('message', handleIsFramedMessage); + }; let mockFrame; beforeEach(async() => { @@ -41,8 +49,8 @@ describe('lms-context-provider client', () => { }); it('is not framed if the window is its own parent', async() => { - sandbox.stub(window, 'parent').value(window); - const listenerSpy = sandbox.spy(); + stub(window, 'parent').value(window); + const listenerSpy = spy(); setUpIsFramedMessageListener(mockFrame, listenerSpy, true); const framed = await isFramed(); @@ -51,8 +59,8 @@ describe('lms-context-provider client', () => { }); it('is not framed if accessing the window parent throws', async() => { - sandbox.stub(window, 'parent').throws(); - const listenerSpy = sandbox.spy(); + stub(window, 'parent').throws(); + const listenerSpy = spy(); setUpIsFramedMessageListener(mockFrame, listenerSpy, true); const framed = await isFramed(); @@ -61,18 +69,20 @@ describe('lms-context-provider client', () => { }); it('is not framed if the host does not respond to an is-framed request', async() => { - sandbox.stub(window, 'parent').value(mockFrame.contentWindow); - const listenerSpy = sandbox.spy(); + stub(window, 'parent').value(mockFrame.contentWindow); + const listenerSpy = spy(); setUpIsFramedMessageListener(mockFrame, listenerSpy, false); - const framed = await isFramed(); - expect(framed).to.be.false; + const framed = isFramed(); + await clock.tickAsync(75); + + expect(await framed).to.be.false; expect(listenerSpy).to.have.been.calledOnce; }); it('is framed if the host responds to an is-framed request', async() => { - sandbox.stub(window, 'parent').value(mockFrame.contentWindow); - const listenerSpy = sandbox.spy(); + stub(window, 'parent').value(mockFrame.contentWindow); + const listenerSpy = spy(); setUpIsFramedMessageListener(mockFrame, listenerSpy, true); const framed = await isFramed(); @@ -97,7 +107,7 @@ describe('lms-context-provider client', () => { window.postMessage({ isContextProvider: isContextProvider, type: type, changedValues: changedValues }, '*'); }; - const setUpMockHostMessageListener = (frame, spy, returnVal, isContextProvider, omitType) => { + const setUpMockHostMessageListener = (frame, spy, respond, returnVal, isContextProvider, omitType) => { isContextProvider = isContextProvider ?? true; const handleMessage = e => { @@ -109,7 +119,7 @@ describe('lms-context-provider client', () => { frame.contentWindow.removeEventListener('message', handleMessage); if (spy) spy(e.data); - if (!returnVal) return; + if (!respond) return; sendResponseMessage(isContextProvider, returnVal, omitType ? undefined : mockContextType); }; @@ -120,15 +130,15 @@ describe('lms-context-provider client', () => { let mockFrame; beforeEach(async() => { mockFrame = await fixture(html``); - sandbox.stub(window, 'parent').value(mockFrame.contentWindow); + stub(window, 'parent').value(mockFrame.contentWindow); }); describe('tryGet', () => { it('returns requested data when provided by the host', async() => { const testVal = 'testVal'; - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy, testVal); + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal); const val = await tryGet(mockContextType, mockOpts); expect(val).to.equal(testVal); @@ -144,11 +154,11 @@ describe('lms-context-provider client', () => { const firstTestVal = 'testVal'; const secondTestVal = 'otherTestVal'; - setUpMockHostMessageListener(mockFrame, undefined, firstTestVal); + setUpMockHostMessageListener(mockFrame, undefined, true, firstTestVal); await tryGet(mockContextType, mockOpts); - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy, secondTestVal); + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, secondTestVal); const secondVal = await tryGet(mockContextType, mockOpts); expect(secondVal).to.equal(secondTestVal); @@ -160,53 +170,65 @@ describe('lms-context-provider client', () => { assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); }); - it('returns undefined when the host does not respond', async() => { - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy); + it('rejects when the host does not respond', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, false); - const val = await tryGet(mockContextType, mockOpts); - expect(val).to.be.undefined; + const val = tryGet(mockContextType, mockOpts); + return val.then(val => { + expect.fail(`Should reject, but ${val} was returned`); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); - expect(requestSpy).to.have.been.calledOnce; - expect(requestSpy.args[0]).to.have.length(1); + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); - const messageData = requestSpy.args[0][0]; - assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); }); - it('ignores host response if isContextProvider is not provided in message', async() => { + it('rejects if isContextProvider is not provided in message', async() => { const testVal = 'testVal'; - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy, testVal, false); + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal, false); - const val = await tryGet(mockContextType, mockOpts); - expect(val).to.be.undefined; + const val = tryGet(mockContextType, mockOpts); + return val.then(val => { + expect.fail(`Should reject, but ${val} was returned`); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); - expect(requestSpy).to.have.been.calledOnce; - expect(requestSpy.args[0]).to.have.length(1); + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); - const messageData = requestSpy.args[0][0]; - assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); }); - it('ignores host response if type is not provided in message', async() => { + it('rejects if type is not provided in message', async() => { const testVal = 'testVal'; - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy, testVal, true, true); + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal, true, true); - const val = await tryGet(mockContextType, mockOpts); - expect(val).to.be.undefined; + const val = tryGet(mockContextType, mockOpts); + return val.then(val => { + expect.fail(`Should reject, but ${val} was returned`); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); - expect(requestSpy).to.have.been.calledOnce; - expect(requestSpy.args[0]).to.have.length(1); + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); - const messageData = requestSpy.args[0][0]; - assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); }); it('does not send subscribe event if onChange callback is not a function', async() => { - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy, undefined); + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true); await tryGet(mockContextType, mockOpts, 'notAFunction'); @@ -222,10 +244,10 @@ describe('lms-context-provider client', () => { testVal: 'testVal' }; - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy, undefined); + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true); - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); // Request a value with an onChange callback to set up subscription await tryGet(mockContextType, mockOpts, subscriptionSpy); @@ -252,9 +274,9 @@ describe('lms-context-provider client', () => { testVal: 'testVal' }; - setUpIsFramedMessageListener(mockFrame, undefined, true); + setUpMockHostMessageListener(mockFrame, undefined, true); - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); // Request a value with an onChange callback to set up subscription await tryGet(mockContextType, mockOpts, subscriptionSpy); @@ -269,9 +291,9 @@ describe('lms-context-provider client', () => { testVal: 'testVal' }; - setUpIsFramedMessageListener(mockFrame, undefined, true); + setUpMockHostMessageListener(mockFrame, undefined, true); - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); // Request a value with an onChange callback to set up subscription await tryGet(mockContextType, mockOpts, subscriptionSpy); @@ -282,9 +304,9 @@ describe('lms-context-provider client', () => { }); it('does not execute onChange callback when changed values are missing from subscription change message', async() => { - setUpIsFramedMessageListener(mockFrame, undefined, true); + setUpMockHostMessageListener(mockFrame, undefined, true); - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); // Request a value with an onChange callback to set up subscription await tryGet(mockContextType, mockOpts, subscriptionSpy); @@ -300,8 +322,8 @@ describe('lms-context-provider client', () => { it('does not provide a return value if the host response includes one', async() => { const testVal = 'testVal'; - const requestSpy = sandbox.spy(); - setUpMockHostMessageListener(mockFrame, requestSpy, testVal); + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal); const val = await tryPerform(mockContextType, mockOpts); expect(val).to.equal(undefined); @@ -313,6 +335,57 @@ describe('lms-context-provider client', () => { assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); }); + it('rejects if the host does not respond', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, false); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + it('rejects if isContextProvider is not provided in message', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, undefined, false); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + it('rejects if type is not provided in message', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, undefined, true, true); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + }); }); @@ -330,10 +403,11 @@ describe('lms-context-provider client', () => { )); }; - const setUpMockHostEventListener = (spy, returnVal) => { + const setUpMockHostEventListener = (spy, handled, returnVal) => { const handleContextRequest = e => { document.removeEventListener('lms-context-request', handleContextRequest); if (spy) spy(e.detail); + e.detail.handled = handled; e.detail.value = returnVal; }; @@ -350,8 +424,8 @@ describe('lms-context-provider client', () => { it('returns requested data when provided by the host', async() => { const testVal = 'testVal'; - const requestSpy = sandbox.spy(); - setUpMockHostEventListener(requestSpy, testVal); + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, testVal); const val = await tryGet(mockContextType, mockOpts); expect(val).to.equal(testVal); @@ -367,11 +441,11 @@ describe('lms-context-provider client', () => { const firstTestVal = 'testVal'; const secondTestVal = 'otherTestVal'; - setUpMockHostEventListener(undefined, firstTestVal); + setUpMockHostEventListener(undefined, true, firstTestVal); await tryGet(mockContextType, mockOpts); - const requestSpy = sandbox.spy(); - setUpMockHostEventListener(requestSpy, secondTestVal); + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, secondTestVal); const secondVal = await tryGet(mockContextType, mockOpts); expect(secondVal).to.equal(secondTestVal); @@ -384,8 +458,8 @@ describe('lms-context-provider client', () => { }); it('does not send subscribe event if onChange callback is not a function', async() => { - const requestSpy = sandbox.spy(); - setUpMockHostEventListener(requestSpy, undefined); + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, undefined); await tryGet(mockContextType, mockOpts, 'notAFunction'); @@ -401,10 +475,10 @@ describe('lms-context-provider client', () => { testVal: 'testVal' }; - const requestSpy = sandbox.spy(); - setUpMockHostEventListener(requestSpy, undefined); + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, undefined); - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); // Request a value with an onChange callback to set up subscription await tryGet(mockContextType, mockOpts, subscriptionSpy); @@ -430,7 +504,9 @@ describe('lms-context-provider client', () => { testVal: 'testVal' }; - const subscriptionSpy = sandbox.spy(); + setUpMockHostEventListener(undefined, true, 'junk'); + + const subscriptionSpy = spy(); // Request a value with an onChange callback to set up subscription await tryGet(mockContextType, mockOpts, subscriptionSpy); @@ -445,8 +521,8 @@ describe('lms-context-provider client', () => { it('does not provide a return value if the host response includes one', async() => { const testVal = 'testVal'; - const requestSpy = sandbox.spy(); - setUpMockHostEventListener(requestSpy, testVal); + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, testVal); const val = await tryPerform(mockContextType, mockOpts); expect(val).to.equal(undefined); @@ -458,6 +534,23 @@ describe('lms-context-provider client', () => { assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); }); + it('rejects if the host does not respond', async() => { + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, false); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + }); + }); }); diff --git a/test/host.test.js b/test/host.test.js index 993fa3e..7737535 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -1,6 +1,7 @@ import { allowFrame, initialize, registerPlugin, reset } from '../src/host/host-internal.js'; import { aTimeout, expect, fixture, html } from '@brightspace-ui/testing'; -import sinon from 'sinon'; +import { restore, spy, stub } from 'sinon'; +import { LmsContextProviderError } from '../src/error.js'; const eventListenerType = 'lms-context-request'; const messageListenerType = 'message'; @@ -9,62 +10,18 @@ const mockContextType = 'test-context'; const otherMockContextType = 'other-test-context'; const mockOpts = { test: 'test' }; -async function sendFramedClientRequest(frame, isContextProvider, type, subscriptionMessageSpy) { - const message = { - isContextProvider: isContextProvider, - type: type, - options: mockOpts, - subscribe: !!subscriptionMessageSpy - }; - - return await new Promise(resolve => { - frame.contentWindow.addEventListener('message', e => { - if (subscriptionMessageSpy) frame.contentWindow.addEventListener('message', subscriptionMessageSpy, { once: true }); - resolve(e.data); - }, { once: true }); - - const script = frame.contentWindow.document.createElement('script'); - script.type = 'text/javascript'; - script.innerHTML = `window.parent.postMessage(${JSON.stringify(message)}, '*');`; - frame.contentWindow.document.head.appendChild(script); - }); -} - -function sendNonFramedClientRequest(type, subscriptionEventSpy) { - const eventDetails = { - type: type, - options: mockOpts, - subscribe: !!subscriptionEventSpy - }; - - if (subscriptionEventSpy) { - document.addEventListener('lms-context-change', e => subscriptionEventSpy(e.detail), { once: true }); - } - - const event = new CustomEvent( - 'lms-context-request', { - detail: eventDetails - } - ); - - document.dispatchEvent(event); - return event.detail.value; -} - describe('lms-context-provider host', () => { - const sandbox = sinon.createSandbox(); - afterEach(() => { reset(); - sandbox.restore(); + restore(); }); describe('initialization', () => { it('sets up appropriate event handlers when initialized', () => { - const docSpy = sandbox.spy(document, 'addEventListener'); - const windowSpy = sandbox.spy(window, 'addEventListener'); + const docSpy = spy(document, 'addEventListener'); + const windowSpy = spy(window, 'addEventListener'); initialize(); @@ -91,14 +48,14 @@ describe('lms-context-provider host', () => { const frame = fixture(html``); it('throws when allowing a frame before initialization', () => { - expect(() => allowFrame(frame)).to.throw; + expect(() => allowFrame(frame)).to.throw(LmsContextProviderError); }); it('throws when attempting to allow a frame that has already been allowed', () => { initialize(); allowFrame(frame); - expect(() => allowFrame(frame)).to.throw; + expect(() => allowFrame(frame)).to.throw(LmsContextProviderError); }); }); @@ -106,25 +63,46 @@ describe('lms-context-provider host', () => { describe('registering plugins', () => { it('throws when host has not yet been initialized', () => { - expect(() => registerPlugin(mockContextType)).to.throw; + expect(() => registerPlugin(mockContextType)).to.throw(LmsContextProviderError); }); it('throws when trying to re-register an existing plugin', () => { initialize(); registerPlugin(mockContextType); - expect(() => registerPlugin(mockContextType)).to.throw; + expect(() => registerPlugin(mockContextType)).to.throw(LmsContextProviderError); }); it('does not throw when registering multiple different plugins', () => { initialize(); registerPlugin(mockContextType); - expect(() => registerPlugin(otherMockContextType)).not.to.throw; + expect(() => registerPlugin(otherMockContextType)).not.to.throw(LmsContextProviderError); }); }); describe('framed client', () => { + const sendFramedClientRequest = async(frame, isContextProvider, type, subscriptionMessageSpy) => { + const message = { + isContextProvider: isContextProvider, + type: type, + options: mockOpts, + subscribe: !!subscriptionMessageSpy + }; + + return await new Promise(resolve => { + frame.contentWindow.addEventListener('message', e => { + if (subscriptionMessageSpy) frame.contentWindow.addEventListener('message', subscriptionMessageSpy, { once: true }); + resolve(e.data); + }, { once: true }); + + const script = frame.contentWindow.document.createElement('script'); + script.type = 'text/javascript'; + script.innerHTML = `window.parent.postMessage(${JSON.stringify(message)}, '*');`; + frame.contentWindow.document.head.appendChild(script); + }); + }; + const assertContextRequestMessageResponse = (messageData, expectedReturnVal, expectedType) => { expect(messageData.isContextProvider).to.be.true; expect(messageData.type).to.equal(expectedType || mockContextType); @@ -145,7 +123,7 @@ describe('lms-context-provider host', () => { it('passes data through when a plugin can handle the request', async() => { const testVal = 'testVal'; - const tryGetStub = sandbox.stub().returns(testVal); + const tryGetStub = stub().returns(testVal); registerPlugin(mockContextType, tryGetStub); allowFrame(mockFrame, window.location.origin); @@ -168,7 +146,7 @@ describe('lms-context-provider host', () => { it('ignores requests without isContextProvider specified', async() => { const testVal = 'testVal'; - const tryGetStub = sandbox.stub().returns(testVal); + const tryGetStub = stub().returns(testVal); registerPlugin(mockContextType, tryGetStub); allowFrame(mockFrame, window.location.origin); @@ -179,19 +157,6 @@ describe('lms-context-provider host', () => { expect(messageData).to.be.undefined; }); - it('ignores requests without a type specified', async() => { - const testVal = 'testVal'; - const tryGetStub = sandbox.stub().returns(testVal); - registerPlugin(mockContextType, tryGetStub); - allowFrame(mockFrame, window.location.origin); - - const messageData = await Promise.race([ - sendFramedClientRequest(mockFrame, true, undefined), - aTimeout(50) - ]); - expect(messageData).to.be.undefined; - }); - it('ignores requests from framed clients that have not been allowed', async() => { const messageData = await Promise.race([ sendFramedClientRequest(mockFrame, true, mockContextType), @@ -215,11 +180,11 @@ describe('lms-context-provider host', () => { otherTestVal: 'otherTestVal' }; - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); registerPlugin(mockContextType, undefined, subscriptionSpy); allowFrame(mockFrame, window.location.origin); - const subscriptionMessageSpy = sandbox.spy(); + const subscriptionMessageSpy = spy(); await sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy); expect(subscriptionSpy).to.have.been.calledOnce; expect(subscriptionSpy.args[0]).to.have.length(1); @@ -243,14 +208,14 @@ describe('lms-context-provider host', () => { }; allowFrame(mockFrame, window.location.origin); - const subscriptionMessageSpy = sandbox.spy(); + const subscriptionMessageSpy = spy(); await sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy); // Shouldn't receive a subscription message before a host plugin has been registered. expect(subscriptionMessageSpy).not.to.have.been.called; // Register a plugin to handle this context request after it has originally been set. - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); registerPlugin(mockContextType, undefined, subscriptionSpy); expect(subscriptionSpy).to.have.been.calledOnce; @@ -269,10 +234,10 @@ describe('lms-context-provider host', () => { }); it('does not send subscription events to framed clients that have not been allowed', async() => { - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); registerPlugin(mockContextType, undefined, subscriptionSpy); - const subscriptionMessageSpy = sandbox.spy(); + const subscriptionMessageSpy = spy(); const messageData = await Promise.race([ sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy), aTimeout(50) @@ -284,11 +249,11 @@ describe('lms-context-provider host', () => { }); it('does not send subscription events to framed clients with an unexpected origin', async() => { - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); registerPlugin(mockContextType, undefined, subscriptionSpy); allowFrame(mockFrame, 'someFakeOrigin'); - const subscriptionMessageSpy = sandbox.spy(); + const subscriptionMessageSpy = spy(); const messageData = await Promise.race([ sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy), aTimeout(50) @@ -303,31 +268,58 @@ describe('lms-context-provider host', () => { describe('unframed client', () => { + const sendNonFramedClientRequest = (type, subscriptionEventSpy) => { + const eventDetails = { + type: type, + options: mockOpts, + subscribe: !!subscriptionEventSpy + }; + + if (subscriptionEventSpy) { + document.addEventListener('lms-context-change', e => subscriptionEventSpy(e.detail), { once: true }); + } + + const event = new CustomEvent( + 'lms-context-request', { + detail: eventDetails + } + ); + + document.dispatchEvent(event); + return { + handled: event.detail.handled, + value: event.detail.value + }; + }; + beforeEach(() => { initialize(); }); it('passes data through when a plugin can handle the request', () => { const testVal = 'testVal'; - const tryGetStub = sandbox.stub().returns(testVal); + const tryGetStub = stub().returns(testVal); registerPlugin(mockContextType, tryGetStub); - const val = sendNonFramedClientRequest(mockContextType); - expect(val).to.equal(testVal); + const { handled, value } = sendNonFramedClientRequest(mockContextType); + expect(handled).to.be.true; + expect(value).to.equal(testVal); }); it('returns undefined when no host plugin can handle the request', () => { - const val = sendNonFramedClientRequest(mockContextType); - expect(val).to.be.undefined; + const { handled, value } = sendNonFramedClientRequest(mockContextType); + expect(handled).to.be.true; + expect(value).to.be.undefined; }); it('ignores requests without a type specified', async() => { const testVal = 'testVal'; - const tryGetStub = sandbox.stub().returns(testVal); + const tryGetStub = stub().returns(testVal); registerPlugin(mockContextType, tryGetStub); - const val = sendNonFramedClientRequest(); - expect(val).to.be.undefined; + const { handled, value } = sendNonFramedClientRequest(); + expect(handled).to.be.undefined; + expect(value).to.be.undefined; }); it('sends subscription events when a client has requested a subscription', () => { @@ -336,10 +328,10 @@ describe('lms-context-provider host', () => { otherTestVal: 'otherTestVal' }; - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); registerPlugin(mockContextType, undefined, subscriptionSpy); - const subscriptionEventSpy = sandbox.spy(); + const subscriptionEventSpy = spy(); sendNonFramedClientRequest(mockContextType, subscriptionEventSpy); expect(subscriptionSpy).to.have.been.calledOnce; expect(subscriptionSpy.args[0]).to.have.length(1); @@ -361,14 +353,14 @@ describe('lms-context-provider host', () => { otherTestVal: 'otherTestVal' }; - const subscriptionEventSpy = sandbox.spy(); + const subscriptionEventSpy = spy(); sendNonFramedClientRequest(mockContextType, subscriptionEventSpy); // Shouldn't receive a subscription event before a host plugin has been registered. expect(subscriptionEventSpy).not.to.have.been.called; // Register a plugin to handle this context request after it has originally been set. - const subscriptionSpy = sandbox.spy(); + const subscriptionSpy = spy(); registerPlugin(mockContextType, undefined, subscriptionSpy); expect(subscriptionSpy).to.have.been.calledOnce; From b679ced46e6fb477a6d590c0ae8c3b82a4d4cd64 Mon Sep 17 00:00:00 2001 From: Grant Cleary Date: Thu, 25 Apr 2024 17:30:45 -0400 Subject: [PATCH 5/6] Update README for better flow --- README.md | 84 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index be338eb..c39bd9e 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,52 @@ npm install @d2l/lms-context-provider ## Usage +### Using a Client + +#### Requesting Data + +To request data, import `tryGet` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. The third argument is an optional callback to allow a consumer to subscribe to future changes to the data they're requesting. + +```js +import { tryGet } from '@d2l/lms-context-provider/client.js'; + +const val = await tryGet( + 'my-context-type', + { someProp: someVal }, + (changedValues) => { + // This callback should accept a single argument: + // an object containing any relevant information from the host plugin + if (changedValues.someChangedProp === 'someVal') { + doSomeWork(changedValues.someChangedProp); + } + } +); +doSomeWork(val); +``` + +If no host plugin is registered to handle a request, or if the data being requested isn't available, the host will return `undefined`. The host plugin may also need to rely on asynchronous methods to return data, so client code should be resilient to receiving a promise that doesn't resolve or takes some time to resolve. + +If no host has been initialized, `tryGet` will reject with an error. + +#### Performing an Action + +To initiate an action on the host but doesn't require return data, import `tryPerform` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. It is not possible to subscribe to change events using this function. + +```js +import { tryPerform } from '@d2l/lms-context-provider/client.js;' + +await tryPerform('my-context-type', { someProp: someVal }); +``` + +If no host plugin is registered to handle a request, or if the data being requested isn't available, this promise will immediately resolve and nothing will happen. As with the `tryGet` function, the host plugin may need to perform asynchronous actions to fulfill a request, so this promise may also never resolve, or may take some time to resolve. + +If no host has been initialized, `tryPerform` will reject with an error. + ### Configuring a Host #### Initializing -Initializing a host should rarely be necessary. Within a Brightspace instance, this will generally be handled by BSI via our MVC and legacy frameworks. If you do need to initialize a host, simply import and execute the `initialize` function. +Initializing a host should rarely be necessary. Within a Brightspace instance, this will generally be handled by BSI via our MVC and legacy frameworks. To initialize a host, import and execute the `initialize` function. ```js import { initialize } from '@d2l/lms-context-provider/host.js'; @@ -26,7 +67,7 @@ initialize(); #### Registering Plugins -To register a host plugin, import and execute the `registerPlugin` function on a page where a host has already been initialized. The provided context type should be unique per page. If your plugin needs to return data to a client, it should provide a `tryGetCallback` as the second argument. If clients can be notified when the data changes, then it should provide a `subscribeCallback` as the third argument. +To register a host plugin, import and execute the `registerPlugin` function on a page where a host has already been initialized. The provided context type should be unique per page. If a plugin needs to return data to a client, it should provide a `tryGetCallback` as the second argument. If clients can be notified when the data changes, then it should provide a `subscribeCallback` as the third argument. ```js import { registerPlugin } from '@d2l/lms-context/provider/host.js'; @@ -56,7 +97,7 @@ registerPlugin('my-context-type', tryGetCallback, subscribeCallback); #### Framed Clients -When working with a client inside an iframe, the host page needs to explicitly allow that iframe. To do this, import and execute `allowFrame` from the host page. The host must already be initialized. The first argument must be the iframe element itself. The second argument should be the expected origin. Requests from clients within iframes that have not explicitly been allowed or that come from a different origin will be ignored. +When working with a client inside an iframe, the host page needs to explicitly allow that iframe. To do this, import and execute `allowFrame` from the host page (the host must already be initialized.). The first argument must be the iframe element itself. The second argument should be the expected origin. Requests from clients within iframes that have not explicitly been allowed or that come from a different origin will be rejected. ```js import { allowFrame } from '@d2l/lms-context-provider/host.js'; @@ -67,43 +108,6 @@ document.body.append(myFrame); allowFrame(myFrame, window.location.origin); ``` -### Using a Client - -#### Requesting Data - -When your library or component is expecting data to be returned, import `tryGet` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. The third argument is an optional callback to allow a consumer to subscribe to future changes to the data they're requesting. - -```js -import { tryGet } from '@d2l/lms-context-provider/client.js'; - -const val = await tryGet( - 'my-context-type', - { someProp: someVal }, - (changedValues) => { - // This callback should accept a single argument: - // an object containing any relevant information from the host plugin - if (changedValues.someChangedProp === 'someVal') { - doSomeWork(changedValues.someChangedProp); - } - } -); -doSomeWork(val); -``` - -If no host plugin is registered to handle your request, or if the data being requested isn't available, the host will return `undefined`. The host plugin may also need to rely on asynchronous methods to return data, so your code should be resilient to receiving a promise that doesn't resolve or takes some time to resolve. - -#### Performing an Action - -If your library or component needs to initiate an action on the host but doesn't require return data, import `tryPerform` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. It is not possible to subscribe to change events using this function. - -```js -import { tryPerform } from '@d2l/lms-context-provider/client.js;' - -await tryPerform('my-context-type', { someProp: someVal }); -``` - -If no host plugin is registered to handle your request, or if the data being requested isn't available, this promise will immediately resolve and nothing will happen. As with the `tryGet` function, the host plugin may need to perform asynchronous actions to fulfill your request, so this promise may also never resolve, or may take some time to resolve. - ## Developing, Testing and Contributing After cloning the repo, run `npm install` to install dependencies. From 692bc9fcbcc5a6825a634db86997f0886f263b90 Mon Sep 17 00:00:00 2001 From: Grant Cleary Date: Thu, 25 Apr 2024 17:47:45 -0400 Subject: [PATCH 6/6] Add tests for custom error --- test/error.test.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/error.test.js diff --git a/test/error.test.js b/test/error.test.js new file mode 100644 index 0000000..14fcf68 --- /dev/null +++ b/test/error.test.js @@ -0,0 +1,27 @@ +import { expect, } from '@brightspace-ui/testing'; +import { LmsContextProviderError } from '../src/error.js'; + +describe('lms-context-provider error', () => { + + it('sets a custom name', () => { + const err = new LmsContextProviderError(); + expect(err.name).to.equal('LmsContextProviderError'); + }); + + it('extends the generic Error class', () => { + const err = new LmsContextProviderError(); + expect(err).to.be.an.instanceof(Error); + }); + + it('pre-pends an identifier to the beginning of the provided error message', () => { + const message = 'message'; + const err = new LmsContextProviderError(message); + + const errParts = err.message.split(' '); + expect(errParts).to.have.length(2); + + expect(errParts[0]).to.equal('lms-context-provider:'); + expect(errParts[1]).to.equal(message); + }); + +});