ForemMobile namespaced functions (#15212)

* window.ForemMobile namespaced functions

* Fix broken Buildkite

* Wait for data-loaded before using Runtime in pack

* Bring back sprockets base

* Better error handling

* Smal fixes to ConsumerApp to support Android platform

* Fix typo

* utilities/waitForDataLoaded.js

* Update app/javascript/utilities/waitForDataLoaded.js

Co-authored-by: Nick Taylor <nick@forem.com>

* Review feedback

* Fix failing spec

* Cleanup promise

* Remove old utility file

* Add Cypress check for namespaced availability

* More specs

* Refactor to rely on ForemMobile for native bridge messaging

* Fix spec/requests/api/v0/instances_spec.rb

* Fix devices spec

* Remove changes to /api/instance

* Refactor to dynamic import

* Fix typo

* Fixed custom event detail payload for tests.

* mock ForemMobile function instead of webkit call directly

* failing jest test debug

* Another attempt

* Fixed broken test that was missing an onMainImageUrlChange prop on the component.

* Fixed mobile bridging E2E tests.

* Move Cypress tests to mobileFlows

* Add JSDocs + cypress spec for injectJSMessage when logged out

* Cleanup + Disable native image upload until AppStore approval

Co-authored-by: Nick Taylor <nick@forem.com>
Co-authored-by: Nick Taylor <nick@dev.to>
This commit is contained in:
Fernando Valverde 2021-11-23 15:25:29 -06:00 committed by GitHub
parent c384755bae
commit 75a722bd6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 408 additions and 179 deletions

View file

@ -473,19 +473,9 @@ function initializePodcastPlayback() {
}
}
function handlePodcastMessages(mutation) {
if (mutation.type !== 'attributes') {
return;
}
var message = {};
try {
var messageData = getById('audiocontent').dataset.podcast;
message = JSON.parse(messageData);
} catch (e) {
console.log(e); // eslint-disable-line no-console
return;
}
function handlePodcastMessages(event) {
const message = JSON.parse(event.detail);
if (message.namespace !== 'podcast') { return }
var currentState = currentAudioState();
switch (message.action) {
@ -499,41 +489,24 @@ function initializePodcastPlayback() {
updateProgress(currentState.currentTime, currentState.duration, 100);
break;
default:
console.log('Unrecognized podcast message: ', message); // eslint-disable-line no-console
console.log('Unrecognized message: ', message); // eslint-disable-line no-console
}
saveMediaState(currentState);
}
function addMutationObserver() {
var mutationObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
handlePodcastMessages(mutation);
});
});
mutationObserver.observe(getById('audiocontent'), { attributes: true });
}
// When Runtime.podcastMessage is undefined we need to execute web logic
function initRuntime() {
if (Runtime.isNativeIOS('podcast')) {
deviceType = 'iOS';
Runtime.podcastMessage = function (message) {
try {
window.webkit.messageHandlers.podcast.postMessage(message);
} catch (err) {
console.log(err.message); // eslint-disable-line no-console
}
};
} else if (Runtime.isNativeAndroid('podcastMessage')) {
deviceType = 'Android';
Runtime.podcastMessage = function (message) {
try {
AndroidBridge.podcastMessage(JSON.stringify(message));
} catch (err) {
console.log(err.message); // eslint-disable-line no-console
}
};
}
if (deviceType !== 'web') {
Runtime.podcastMessage = (msg) => {
window.ForemMobile.injectNativeMessage('podcast', msg);
}
}
}
@ -560,7 +533,7 @@ function initializePodcastPlayback() {
updateProgressListener(audio),
false,
);
addMutationObserver();
document.addEventListener('ForemMobile', handlePodcastMessages);
}, 500);
applyOnclickToPodcastBar(audio);
}

View file

@ -122,17 +122,9 @@ function initializeVideoPlayback() {
videoPlayerEvent(true);
}
function handleVideoMessages(mutation) {
if (mutation.type !== 'attributes') {
return;
}
var message = {};
try {
var messageData = getById('video-player-source').dataset.message;
message = JSON.parse(messageData);
} catch (e) {
console.log(e); // eslint-disable-line no-console
function handleVideoMessages(event) {
const message = JSON.parse(event.detail);
if (message.namespace !== 'video') {
return;
}
@ -151,7 +143,7 @@ function initializeVideoPlayback() {
currentTime = message.currentTime;
break;
default:
console.log('Unrecognized video message: ', message); // eslint-disable-line no-console
console.log('Unrecognized message: ', message); // eslint-disable-line no-console
}
}
@ -161,40 +153,25 @@ function initializeVideoPlayback() {
if (Runtime.isNativeIOS('video')) {
deviceType = 'iOS';
Runtime.videoMessage = function (message) {
try {
window.webkit.messageHandlers.video.postMessage(message);
} catch (err) {
console.log(err.message); // eslint-disable-line no-console
}
};
} else if (Runtime.isNativeAndroid('videoMessage')) {
deviceType = 'Android';
Runtime.videoMessage = function (message) {
try {
AndroidBridge.videoMessage(JSON.stringify(message));
} catch (err) {
console.log(err.message); // eslint-disable-line no-console
}
};
} else {
// jwplayer is initialized and no further interaction is needed
initWebPlayer(seconds, metadata);
return;
}
Runtime.videoMessage = (msg) => {
window.ForemMobile.injectNativeMessage('video', msg);
};
var playerElement = getById(`video-player-${metadata.id}`);
playerElement.addEventListener('click', requestFocus);
playerElement.classList.add('native');
getById('play-butt').classList.add('active');
var mutationObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
handleVideoMessages(mutation);
});
});
mutationObserver.observe(videoSource, { attributes: true });
document.addEventListener('ForemMobile', handleVideoMessages);
currentTime = `${seconds}`;
}

View file

@ -42,7 +42,7 @@ class DevicesController < ApplicationController
user: current_user,
token: params[:token],
platform: params[:platform],
consumer_app: ConsumerApp.find_by(app_bundle: params[:app_bundle])
consumer_app: consumer_app
}
end
@ -57,7 +57,11 @@ class DevicesController < ApplicationController
user_id: params[:id],
token: params[:token],
platform: params[:platform],
consumer_app: ConsumerApp.find_by(app_bundle: params[:app_bundle])
consumer_app: consumer_app
}
end
def consumer_app
ConsumerApp.find_by(app_bundle: params[:app_bundle], platform: params[:platform])
end
end

View file

@ -14,7 +14,6 @@ const NativeIosImageUpload = ({
extraProps,
uploadLabel,
isUploadingImage,
handleNativeMessage,
}) => (
<Fragment>
{isUploadingImage ? null : (
@ -26,12 +25,6 @@ const NativeIosImageUpload = ({
{uploadLabel}
</Button>
)}
<input
type="hidden"
id="native-cover-image-upload-message"
value=""
onChange={handleNativeMessage}
/>
</Fragment>
);
@ -98,20 +91,27 @@ export class ArticleCoverImage extends Component {
};
useNativeUpload = () => {
return Runtime.isNativeIOS('imageUpload');
// This namespace is not implemented in the native side. This allows us to
// deploy our refactor and wait until our iOS app is approved by AppStore
// review. The old web implementation will be the fallback until then.
return Runtime.isNativeIOS('imageUpload_disabled');
};
initNativeImagePicker = (e) => {
e.preventDefault();
window.webkit.messageHandlers.imageUpload.postMessage({
id: 'native-cover-image-upload-message',
window.ForemMobile?.injectNativeMessage('coverUpload', {
action: 'coverImageUpload',
ratio: `${100.0 / 42.0}`,
});
};
handleNativeMessage = (e) => {
const message = JSON.parse(e.target.value);
const message = JSON.parse(e.detail);
if (message.namespace !== 'coverUpload') {
return;
}
/* eslint-disable no-case-declarations */
switch (message.action) {
case 'uploading':
this.setState({ uploadingImage: true });
@ -125,10 +125,14 @@ export class ArticleCoverImage extends Component {
});
break;
case 'success':
this.props.onMainImageUrlChange({ links: [message.link] });
const { onMainImageUrlChange } = this.props;
onMainImageUrlChange({
links: [message.link],
});
this.setState({ uploadingImage: false });
break;
}
/* eslint-enable no-case-declarations */
};
triggerMainImageRemoval = (e) => {
@ -169,6 +173,9 @@ export class ArticleCoverImage extends Component {
}
: {};
// Native Bridge messages come through ForemMobile events
document.addEventListener('ForemMobile', this.handleNativeMessage);
return (
<DragAndDropZone
onDragOver={onDragOver}
@ -198,7 +205,6 @@ export class ArticleCoverImage extends Component {
isUploadingImage={uploadingImage}
extraProps={extraProps}
uploadLabel={uploadLabel}
handleNativeMessage={this.handleNativeMessage}
/>
) : (
<StandardImageUpload

View file

@ -66,11 +66,7 @@ function imageUploaderReducer(state, action) {
}
}
const NativeIosImageUpload = ({
uploadingImage,
extraProps,
handleNativeMessage,
}) => (
const NativeIosImageUpload = ({ uploadingImage, extraProps }) => (
<Fragment>
{!uploadingImage && (
<Button
@ -83,12 +79,6 @@ const NativeIosImageUpload = ({
Upload image
</Button>
)}
<input
type="hidden"
id="native-image-upload-message"
value=""
onChange={handleNativeMessage}
/>
</Fragment>
);
@ -177,7 +167,10 @@ export const ImageUploader = () => {
}
function handleNativeMessage(e) {
const message = JSON.parse(e.target.value);
const message = JSON.parse(e.detail);
if (message.namespace !== 'imageUpload') {
return;
}
switch (message.action) {
case 'uploading':
@ -202,8 +195,8 @@ export const ImageUploader = () => {
function initNativeImagePicker(e) {
e.preventDefault();
window.webkit.messageHandlers.imageUpload.postMessage({
id: 'native-image-upload-message',
window.ForemMobile?.injectNativeMessage('imageUpload', {
action: 'imageUpload',
});
}
@ -211,11 +204,20 @@ export const ImageUploader = () => {
// image picker for image upload we want to add the aria-label attr and the
// onClick event to the UI button. This event will kick off the native UX.
// The props are unwrapped (using spread operator) in the button below
const useNativeUpload = Runtime.isNativeIOS('imageUpload');
//
//
//
// This namespace is not implemented in the native side. This allows us to
// deploy our refactor and wait until our iOS app is approved by AppStore
// review. The old web implementation will be the fallback until then.
const useNativeUpload = Runtime.isNativeIOS('imageUpload_disabled');
const extraProps = useNativeUpload
? { onClick: initNativeImagePicker, 'aria-label': 'Upload an image' }
: { tabIndex: -1 };
// Native Bridge messages come through ForemMobile events
document.addEventListener('ForemMobile', handleNativeMessage);
return (
<div className="flex items-center">
{uploadingImage && (
@ -228,7 +230,6 @@ export const ImageUploader = () => {
<NativeIosImageUpload
extraProps={extraProps}
uploadingImage={uploadingImage}
handleNativeMessage={handleNativeMessage}
/>
) : (
<StandardImageUpload

View file

@ -3,6 +3,7 @@ import {
render,
fireEvent,
waitForElementToBeRemoved,
createEvent,
} from '@testing-library/preact';
import { axe } from 'jest-axe';
import fetch from 'jest-fetch-mock';
@ -154,16 +155,16 @@ describe('<ArticleCoverImage />', () => {
await findByText(/some fake error/i);
});
describe('when rendered in native iOS with imageUpload support', () => {
describe('when rendered in native iOS with imageUpload_disabled support', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn((namespace) => {
return namespace === 'imageUpload';
return namespace === 'imageUpload_disabled';
}),
};
});
it('should have no a11y violations when native iOS imageUpload support is available', async () => {
it('should have no a11y violations when native iOS imageUpload_disabled support is available', async () => {
const { container } = render(
<ArticleCoverImage
mainImage="/i/r5tvutqpl7th0qhzcw7f.png"
@ -182,63 +183,71 @@ describe('<ArticleCoverImage />', () => {
});
it('triggers a webkit messageHandler call when isNativeIOS', async () => {
global.window.webkit = {
messageHandlers: {
imageUpload: {
postMessage: jest.fn(),
},
},
};
global.window.ForemMobile = { injectNativeMessage: jest.fn() };
const { queryByLabelText } = render(<ArticleCoverImage mainImage="" />);
const { queryByLabelText } = render(
<ArticleCoverImage mainImage="" onMainImageUrlChange={jest.fn()} />,
);
const uploadButton = queryByLabelText(/Upload cover image/i);
uploadButton.click();
expect(
window.webkit.messageHandlers.imageUpload.postMessage,
global.window.ForemMobile.injectNativeMessage,
).toHaveBeenCalledTimes(1);
});
describe('when an image is uploaded', () => {
it('successfully uploads an image', async () => {
const onMainImageUrlChange = jest.fn();
const { container } = render(
const onMainImageUrlChangeSpy = jest.fn();
render(
<ArticleCoverImage
mainImage=""
onMainImageUrlChange={onMainImageUrlChange}
onMainImageUrlChange={onMainImageUrlChangeSpy}
/>,
);
const nativeInput = container.querySelector(
'#native-cover-image-upload-message',
);
// Fire a change event in the hidden input with JSON payload for success
const fakeSuccessMessage = `{ "action": "success", "link": "/some-fake-image.jpg" }`;
fireEvent.change(nativeInput, {
target: { value: fakeSuccessMessage },
const fakeSuccessMessage = JSON.stringify({
action: 'success',
link: '/some-fake-image.jpg',
namespace: 'coverUpload',
});
const event = createEvent(
'ForemMobile',
document,
{ detail: fakeSuccessMessage },
{ EventType: 'CustomEvent' },
);
fireEvent(document, event);
expect(onMainImageUrlChange).toHaveBeenCalledTimes(1);
expect(onMainImageUrlChangeSpy).toHaveBeenCalledTimes(1);
});
it('displays an upload error when necessary', async () => {
const onMainImageUrlChange = jest.fn();
/* eslint-disable no-unused-vars */
const { container, findByText } = render(
<ArticleCoverImage
mainImage=""
onMainImageUrlChange={onMainImageUrlChange}
/>,
);
const nativeInput = container.querySelector(
'#native-cover-image-upload-message',
);
/* eslint-enable no-unused-vars */
const error = 'oh no!';
// Fire a change event in the hidden input with JSON payload for an error
const fakeErrorMessage = JSON.stringify({ action: 'error', error });
fireEvent.change(nativeInput, {
target: { value: fakeErrorMessage },
const fakeErrorMessage = JSON.stringify({
action: 'error',
error,
namespace: 'coverUpload',
});
const event = createEvent(
'ForemMobile',
document,
{ detail: fakeErrorMessage },
{ EventType: 'CustomEvent' },
);
fireEvent(document, event);
await findByText(error);
@ -247,21 +256,27 @@ describe('<ArticleCoverImage />', () => {
it('displays an uploading message', async () => {
const onMainImageUrlChange = jest.fn();
/* eslint-disable no-unused-vars */
const { container, findByText } = render(
<ArticleCoverImage
mainImage=""
onMainImageUrlChange={onMainImageUrlChange}
/>,
);
const nativeInput = container.querySelector(
'#native-cover-image-upload-message',
);
/* eslint-enable no-unused-vars */
// Fire a change event in the hidden input with JSON payload for an error
const fakeUploadingMessage = JSON.stringify({ action: 'uploading' });
fireEvent.change(nativeInput, {
target: { value: fakeUploadingMessage },
const fakeUploadingMessage = JSON.stringify({
action: 'uploading',
namespace: 'coverUpload',
});
const event = createEvent(
'ForemMobile',
document,
{ detail: fakeUploadingMessage },
{ EventType: 'CustomEvent' },
);
fireEvent(document, event);
await findByText(/Uploading.../i);

View file

@ -3,6 +3,7 @@ import {
render,
fireEvent,
waitForElementToBeRemoved,
createEvent,
} from '@testing-library/preact';
import { axe } from 'jest-axe';
import fetch from 'jest-fetch-mock';
@ -33,11 +34,11 @@ describe('<ImageUploader />', () => {
expect(uploadInput.getAttribute('type')).toEqual('file');
});
describe('when rendered in native iOS with imageUpload support', () => {
describe('when rendered in native iOS with imageUpload_disabled support', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn((namespace) => {
return namespace === 'imageUpload';
return namespace === 'imageUpload_disabled';
}),
};
});
@ -48,31 +49,32 @@ describe('<ImageUploader />', () => {
});
it('triggers a webkit messageHandler call when isNativeIOS', async () => {
global.window.webkit = {
messageHandlers: {
imageUpload: {
postMessage: jest.fn(),
},
},
};
global.window.ForemMobile = { injectNativeMessage: jest.fn() };
const { queryByLabelText } = render(<ImageUploader />);
const uploadButton = queryByLabelText(/Upload an image/i);
uploadButton.click();
expect(
window.webkit.messageHandlers.imageUpload.postMessage,
global.window.ForemMobile.injectNativeMessage,
).toHaveBeenCalledTimes(1);
});
it('handles a native bridge message correctly', async () => {
const { container, findByTitle } = render(<ImageUploader />);
const nativeInput = container.querySelector(
'#native-image-upload-message',
);
const { container, findByTitle } = render(<ImageUploader />); // eslint-disable-line no-unused-vars
// Fire a change event in the hidden input with JSON payload for success
const fakeSuccessMessage = `{ "action": "success", "link": "/some-fake-image.jpg" }`;
fireEvent.change(nativeInput, { target: { value: fakeSuccessMessage } });
const fakeSuccessMessage = JSON.stringify({
action: 'success',
link: '/some-fake-image.jpg',
namespace: 'imageUpload',
});
const event = createEvent(
'ForemMobile',
document,
{ detail: fakeSuccessMessage },
{ EventType: 'CustomEvent' },
);
fireEvent(document, event);
expect(await findByTitle(/copy markdown for image/i)).toBeDefined();
});

View file

@ -0,0 +1,88 @@
/* global Runtime */
import { request } from '@utilities/http';
export function foremMobileNamespace() {
return {
retryDelayMs: 700,
getUserData() {
return document.body.dataset.user;
},
getInstanceMetadata() {
return JSON.stringify({
name: document.querySelector("meta[property='forem:name']")['content'],
logo: document.querySelector("meta[property='forem:logo']")['content'],
domain: document.querySelector("meta[property='forem:domain']")[
'content'
],
});
},
registerDeviceToken(token, appBundle, platform) {
request(`/users/devices`, {
method: 'POST',
body: { token, platform, app_bundle: appBundle },
credentials: 'same-origin',
})
.then((response) => response.json())
.then((response) => {
if (
!isNaN(parseInt(response.id, 10)) &&
response.error == undefined
) {
// Clear the interval if the registration succeeded
clearInterval(window.ForemMobile.deviceRegistrationInterval);
window.ForemMobile.retryDelayMs = 700;
} else {
// Registration failed - throw and log error message
throw new Error(response.error);
}
})
.catch((error) => {
Honeybadger.notify(error);
// Increase backoff delay time
if (window.ForemMobile.retryDelayMs < 20000) {
window.ForemMobile.retryDelayMs =
window.ForemMobile.retryDelayMs * 2;
}
// Attempt to register again after delay
setTimeout(() => {
window.ForemMobile.registerDeviceToken(token, appBundle, platform);
}, window.ForemMobile.retryDelayMs);
});
},
unregisterDeviceToken(userId, token, appBundle, platform) {
request(`/users/devices/${userId}`, {
method: 'DELETE',
body: { token, platform, app_bundle: appBundle },
credentials: 'same-origin',
});
},
injectJSMessage(message) {
const event = new CustomEvent('ForemMobile', { detail: message });
document.dispatchEvent(event);
},
injectNativeMessage(namespace, message) {
try {
if (Runtime.isNativeIOS(namespace)) {
window.webkit.messageHandlers[namespace].postMessage(message);
} else if (Runtime.isNativeAndroid(namespace)) {
AndroidBridge[`${namespace}Message`](JSON.stringify(message));
}
} catch (error) {
Honeybadger.notify(error);
}
},
userSessionBroadcast() {
const currentUser = document.body.dataset.user;
if (currentUser) {
window.ForemMobile.injectNativeMessage(
'userLogin',
JSON.parse(currentUser),
);
} else {
window.ForemMobile.injectNativeMessage('userLogout', {});
}
},
};
}

View file

@ -1,9 +1,10 @@
/* global Runtime */
import {
initializeMobileMenu,
setCurrentPageIconLink,
getInstantClick,
initializeMemberMenu,
} from '../topNavigation/utilities';
import { waitOnBaseData } from '../utilities/waitOnBaseData';
// Unique ID applied to modals created using window.Forem.showModal
const WINDOW_MODAL_ID = 'window-modal';
@ -136,11 +137,26 @@ if (memberMenu) {
initializeMemberMenu(memberMenu, menuNavButton);
}
getInstantClick().then((spa) => {
spa.on('change', () => {
initializeNav();
// Initialize when asset pipeline (sprockets) initializers have executed
waitOnBaseData()
.then(() => {
InstantClick.on('change', () => {
initializeNav();
});
if (Runtime.currentMedium() === 'ForemWebView') {
// Dynamic import of the namespace
import('../mobile/foremMobile.js').then((module) => {
// Load the namespace
window.ForemMobile = module.foremMobileNamespace();
// Run the first session
window.ForemMobile.userSessionBroadcast();
});
}
})
.catch((error) => {
Honeybadger.notify(error);
});
});
initializeNav();

View file

@ -1,5 +1,6 @@
import { h, render } from 'preact';
import { RuntimeBanner } from '../runtimeBanner';
import { waitOnBaseData } from '../utilities/waitOnBaseData';
function loadElement() {
const container = document.getElementById('runtime-banner-container');
@ -8,25 +9,19 @@ function loadElement() {
}
}
function initializeBannerWhenPageIsReady() {
setTimeout(() => {
if (document.body.getAttribute('data-loaded') === 'true') {
// We're ready to initialize
window.InstantClick.on('change', () => {
loadElement();
});
loadElement();
} else {
// Page hasn't initialized yet. We need to wait until the page is ready
initializeBannerWhenPageIsReady();
}
}, 100);
}
// This pack relies on the same logic as `packs/listings` & `packs/Chat`. The
// banner lives in every page (including the main feed) and a race condition
// occurs when the page initializes for the first time or when signing out (no
// cache request). In order to avoid this we defer the initialization until the
// page is actually ready.
initializeBannerWhenPageIsReady();
waitOnBaseData()
.then(() => {
window.InstantClick.on('change', () => {
loadElement();
});
loadElement();
})
.catch((error) => {
Honeybadger.notify(error);
});

View file

@ -0,0 +1,20 @@
/**
* A util function to wrap any code that needs to wait until the page has
* initialized correctly before executing. This is generally the case for
* packs/components that require `/app/assets/initializers` to execute first,
* this way you're ensured that global functions/namespaces will be available
* (i.e. the Runtime class).
*
* @returns {Promise} A chainable promise that will fulfill when the page has
* loaded correctly and all initializers have run.
*/
export function waitOnBaseData() {
return new Promise((resolve) => {
const waitingForDataLoad = setInterval(() => {
if (document.body.getAttribute('data-loaded') === 'true') {
clearInterval(waitingForDataLoad);
resolve();
}
}, 100);
});
}

View file

@ -2,7 +2,7 @@ class ConsumerApp < ApplicationRecord
resourcify
FOREM_BUNDLE = "com.forem.app".freeze
FOREM_APP_PLATFORMS = %w[ios].freeze
FOREM_APP_PLATFORMS = %w[ios android].freeze
FOREM_TEAM_ID = "R9SWHSQNV8".freeze
enum platform: { android: Device::ANDROID, ios: Device::IOS }
@ -35,8 +35,10 @@ class ConsumerApp < ApplicationRecord
# custom PN targets will get their credentials from the auth_key stored in
# the DB (configured by the Forem creator).
def auth_credentials
if forem_app?
if forem_app? && ios?
ApplicationConfig["RPUSH_IOS_PEM"]
elsif forem_app? && android?
ApplicationConfig["RPUSH_FCM_KEY"]
else
auth_key
end

View file

@ -18,12 +18,11 @@
<% end %>
<meta name="environment" content="<%= Rails.env %>">
<%= render "layouts/styles", qualifier: "main" %>
<% unless user_signed_in? %>
<%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", defer: true %>
<% end %>
<%= javascript_include_tag "base", defer: true %>
<% if user_signed_in? %>
<%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", "onboardingRedirectCheck", "contentDisplayPolicy", defer: true %>
<% else %>
<%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", defer: true %>
<% end %>
<%= yield(:page_meta) %>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">

View file

@ -0,0 +1,129 @@
// This E2E test focuses on ensuring the mobile bridge integration
describe('Namespaced ForemMobile functions', () => {
function waitForBaseDataLoaded() {
cy.get('body').should('have.attr', 'data-loaded', 'true');
}
// Make the runtime act as ForemWebView context
const runtimeStub = {
onBeforeLoad: (win) => {
Object.defineProperty(win.navigator, 'userAgent', {
value:
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 ForemWebView/1.0',
});
Object.defineProperty(win.navigator, 'platform', { value: 'iPhone' });
},
};
describe('within ForemWebView context', () => {
describe('when logged in', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/adminUser.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user).then(() => {
cy.visit('/', runtimeStub);
cy.get('body').should('have.attr', 'data-user-status', 'logged-in');
waitForBaseDataLoaded();
});
});
});
it('should load the ForemMobile namespaced functions', () => {
cy.window().then((win) => {
assert.isNumber(win.ForemMobile.retryDelayMs);
assert.isFunction(win.ForemMobile.getUserData);
assert.isFunction(win.ForemMobile.getInstanceMetadata);
assert.isFunction(win.ForemMobile.registerDeviceToken);
assert.isFunction(win.ForemMobile.unregisterDeviceToken);
assert.isFunction(win.ForemMobile.injectJSMessage);
assert.isFunction(win.ForemMobile.injectNativeMessage);
assert.isFunction(win.ForemMobile.userSessionBroadcast);
});
});
it('should return the instance metadata JSON when requested', () => {
cy.window().then((win) => {
var res = JSON.parse(win.ForemMobile.getInstanceMetadata());
assert.isObject(res);
assert.equal(res.domain, 'localhost:3000');
assert.isString(res.logo);
assert.isString(res.name);
});
});
it('should return user data when logged in', () => {
cy.window().then((win) => {
cy.fixture('users/adminUser.json').as('user');
cy.get('@user').then((expected) => {
const actual = JSON.parse(win.ForemMobile.getUserData());
expect(actual.username).to.equal(expected.username);
});
});
});
it('should inject messages using CustomEvent', () => {
cy.document()
.then((doc) => {
doc.addEventListener('ForemMobile', cy.stub().as('bridgeEvent'));
})
.then(() => cy.window())
.then((win) => {
win.ForemMobile.injectJSMessage({ action: 'test' });
});
// on load the app should have sent an event
cy.get('@bridgeEvent')
.should('have.been.calledOnce')
.its('firstCall.args.0.detail')
.should('deep.equal', { action: 'test' });
});
});
describe('when logged out', () => {
it('should return empty user data when logged out', () => {
cy.testSetup();
cy.visit('/', runtimeStub);
waitForBaseDataLoaded();
cy.window().then((win) => {
assert.isUndefined(win.ForemMobile.getUserData());
});
});
it('should inject messages using CustomEvent', () => {
cy.document()
.then((doc) => {
doc.addEventListener('ForemMobile', cy.stub().as('bridgeEvent'));
})
.then(() => cy.window())
.then((win) => {
win.ForemMobile.injectJSMessage({ action: 'test' });
});
// on load the app should have sent an event
cy.get('@bridgeEvent')
.should('have.been.calledOnce')
.its('firstCall.args.0.detail')
.should('deep.equal', { action: 'test' });
});
});
});
describe('Non-mobile', () => {
it('should not load namespaced functions on other contexts', () => {
cy.testSetup();
cy.visit('/');
waitForBaseDataLoaded();
cy.get('body')
.invoke('attr', 'data-runtime')
.should('contain', 'Browser-');
cy.window().then((win) => {
assert.isUndefined(win.ForemMobile);
});
});
});
});

View file

@ -41,9 +41,11 @@ RSpec.describe ConsumerApp, type: :model do
platform: ConsumerApp::FOREM_APP_PLATFORMS.sample,
)
allow(ApplicationConfig).to receive(:[]).with("RPUSH_IOS_PEM").and_return("asdf123")
allow(ApplicationConfig).to receive(:[]).with("RPUSH_FCM_KEY").and_return("asdf123")
expect(forem_consumer_app.operational?).to be(true)
allow(ApplicationConfig).to receive(:[]).with("RPUSH_IOS_PEM").and_return(nil)
allow(ApplicationConfig).to receive(:[]).with("RPUSH_FCM_KEY").and_return(nil)
expect(forem_consumer_app.operational?).to be(false)
end
end

View file

@ -13,7 +13,7 @@ RSpec.describe "Devices", type: :request do
it "increases device count" do
post devices_path, params: {
token: "123",
platform: "Android",
platform: "ios",
app_bundle: consumer_app.app_bundle
}
expect(user.devices.count).to eq(1)