Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ AoG: Add amp-viewer-assistance extension. #20725

Merged
merged 14 commits into from Feb 21, 2019
Merged
2 changes: 2 additions & 0 deletions build-system/tasks/compile.js
Expand Up @@ -239,6 +239,8 @@ function compile(entryModuleFilenames, outputDir, outputFilename, options) {
'extensions/amp-consent/**/*.js',
// Needed to access AmpGeo type for service locator
'extensions/amp-geo/**/*.js',
// Needed for AmpViewerAssistanceService
'extensions/amp-viewer-assistance/**/*.js',
// Needed for AmpViewerIntegrationVariableService
'extensions/amp-viewer-integration/**/*.js',
'src/*.js',
Expand Down
1 change: 1 addition & 0 deletions bundles.config.js
Expand Up @@ -394,6 +394,7 @@ exports.extensionBundles = [
],
},
{name: 'amp-google-vrview-image', version: '0.1', type: TYPES.MISC},
{name: 'amp-viewer-assistance', version: '0.1', type: TYPES.MISC},
{
name: 'amp-viewer-integration',
version: '0.1',
Expand Down
189 changes: 189 additions & 0 deletions extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js
@@ -0,0 +1,189 @@
/**
* Copyright 2019 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {ActionTrust} from '../../../src/action-constants';
import {Services} from '../../../src/services';
import {dev, user} from '../../../src/log';
import {dict} from '../../../src/utils/object';
import {isExperimentOn} from '../../../src/experiments';
import {tryParseJson} from '../../../src/json';


/** @const {string} */
const TAG = 'amp-viewer-assistance';

/** @const {string} */
const GSI_TOKEN_PROVIDER = 'actions-on-google-gsi';

export class AmpViewerAssistance {
/**
* @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
*/
constructor(ampdoc) {
const assistanceElement = ampdoc.getElementById('amp-viewer-assistance');

/** @private {boolean} */
this.enabled_ = !!assistanceElement;
if (!this.enabled_) {
return;
}

/** @const @private */
this.ampdoc_ = ampdoc;

/** @const @private {!Element} */
this.assistanceElement_ = dev().assertElement(assistanceElement);

/** @const @private {JsonObject|null|undefined} */
this.configJson_ = tryParseJson(this.assistanceElement_.textContent, e => {
throw user().createError(
'Failed to parse "amp-viewer-assistance" JSON: ' + e);
});

/** @private @const {!../../../src/service/viewer-impl.Viewer} */
this.viewer_ = Services.viewerForDoc(ampdoc);

/** @private @const {!../../../src/service/action-impl.ActionService} */
this.action_ = Services.actionServiceForDoc(this.assistanceElement_);

/** @private @const {!../../../src/service/vsync-impl.Vsync} */
this.vsync_ = Services.vsyncFor(ampdoc.win);
}

/**
* @param {!../../../src/service/action-impl.ActionInvocation} invocation
* @return {?Promise}
* @private
*/
actionHandler_(invocation) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry didn't catch this earlier. You should actually use this.registerAction() here instead. See base-element.js#registerAction.

Then you can also remove the whitelisting in presubmit-checks.js.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we discussed offline, we are following the pattern set by amp-access to justify using our own action handler since we are using a <script> tag instead of an <amp-viewer-assistance> element.

const {method, args} = invocation;
if (method == 'updateActionState' && !!args) {
this.viewer_.sendMessageAwaitResponse(method, args).catch(error => {
user().error(TAG, error.toString());
});
} else if (method == 'signIn') {
this.requestSignIn_();
}

return null;
}

/**
* @private
* @restricted
hellokoji marked this conversation as resolved.
Show resolved Hide resolved
* @return {!AmpViewerAssistance}
*/
start_() {
if (!this.enabled_) {
user().error(TAG, 'Could not find #amp-viewer-assistance element.');
return this;
}
return this.viewer_.isTrustedViewer().then(isTrustedViewer => {
if (!isTrustedViewer &&
!isExperimentOn(this.ampdoc_.win, 'amp-viewer-assistance-untrusted')) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to be enabled eventually?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the experiment was our method of forgoing the trusted viewer check with testing locally since local viewers are inherently not trusted.

If there are other ways of accomplishing the same effect, we are open to suggestions.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, we usually use getMode().localDev for this. It returns true when compiling with --fortesting flag, e.g. gulp dist --extensions=amp-viewer-assistance --fortesting.

Though if you don't do testing with a locally-build AMP runtime, then you'd need local proxy software like Charles to replace the CDN-fetched JS with your local version. Is that the case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is specifically for developing/testing with a local viewer using a remote (or local) runtime. Does that make sense?

this.enabled_ = false;
user().error(TAG,
'amp-viewer-assistance is currently only supported on trusted'
+ ' viewers.');
return this;
}
this.action_.installActionHandler(
this.assistanceElement_, this.actionHandler_.bind(this),
ActionTrust.HIGH);

this.getIdTokenPromise();

this.viewer_.sendMessage('viewerAssistanceConfig',dict({
'config': this.configJson_,
}));
return this;
});
}

/**
* @return {!Promise<undefined>}
*/
getIdTokenPromise() {
return this.viewer_.sendMessageAwaitResponse('getAccessTokenPassive', dict({
// For now there's only 1 provider option, so we just hard code it
'providers': [GSI_TOKEN_PROVIDER],
}))
.then(token => {
this.setIdTokenStatus_(Boolean(!!token));
return token;
}).catch(() => {
this.setIdTokenStatus_(/*available=*/false);
});
}

/**
* @private
*/
requestSignIn_() {
this.viewer_.sendMessageAwaitResponse('requestSignIn', dict({
'providers': [GSI_TOKEN_PROVIDER],
})).then(token => {
user().info(TAG, 'Token: ' + token);
if (token) {
this.setIdTokenStatus_(/*available=*/true);
this.action_.trigger(
this.assistanceElement_, 'signedIn', null, ActionTrust.HIGH);
} else {
this.setIdTokenStatus_(/*available=*/false);
}
hellokoji marked this conversation as resolved.
Show resolved Hide resolved
});
}

/**
* Toggles the CSS classes related to the status of the identity token.
* @private
* @param {boolean} available
*/
setIdTokenStatus_(available) {
this.toggleTopClass_(
'amp-viewer-assistance-identity-available', available);
hellokoji marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Gets the root element of the AMP doc.
* @return {!Element}
* @private
*/
getRootElement_() {
const root = this.ampdoc_.getRootNode();
return dev().assertElement(root.documentElement || root.body || root);
}

/**
* Toggles a class on the root element of the AMP doc.
* @param {string} className
* @param {boolean} on
* @private
*/
toggleTopClass_(className, on) {
this.vsync_.mutate(() => {
this.getRootElement_().classList.toggle(className, on);
});

}
}

// Register the extension services.
AMP.extension(TAG, '0.1', function(AMP) {
AMP.registerServiceForDoc('amp-viewer-assistance', function(ampdoc) {
return new AmpViewerAssistance(ampdoc).start_();
});
});
@@ -0,0 +1,182 @@
/**
* Copyright 2019 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {ActionInvocation} from '../../../../src/service/action-impl';
import {AmpViewerAssistance} from '../amp-viewer-assistance';
import {mockServiceForDoc} from '../../../../testing/test-helper';

describes.fakeWin('AmpViewerAssistance', {
amp: true,
location: 'https://pub.com/doc1',
}, env => {
let document;
let ampdoc;
let element;
let viewerMock;

beforeEach(() => {
ampdoc = env.ampdoc;
document = env.win.document;
viewerMock = mockServiceForDoc(env.sandbox, env.ampdoc, 'viewer', [
'isTrustedViewer',
'sendMessage',
'sendMessageAwaitResponse',
]);
viewerMock.isTrustedViewer.returns(Promise.resolve(true));
viewerMock.sendMessageAwaitResponse.returns(Promise.resolve('idToken'));

element = document.createElement('script');
element.setAttribute('id', 'amp-viewer-assistance');
element.setAttribute('type', 'application/json');
document.body.appendChild(element);
});

it('should disable service when no config', () => {
document.body.removeChild(element);
const service = new AmpViewerAssistance(ampdoc);
expect(service.enabled_).to.be.false;
expect(service.assistanceElement_).to.be.undefined;
});

it('should disable service when the viewer is not trusted', () => {
viewerMock.isTrustedViewer.returns(Promise.resolve(false));
const config = {
'providerId': 'foo-bar',
};
element.textContent = JSON.stringify(config);
const service = new AmpViewerAssistance(ampdoc);
return service.start_().then(() => {
expect(service.enabled_).to.be.false;
});
});

it('should fail if config is malformed', () => {
expect(() => {
new AmpViewerAssistance(ampdoc);
}).to.throw(Error);
});

it('should send the config to the viewer', () => {
const config = {
'providerId': 'foo-bar',
};
element.textContent = JSON.stringify(config);
const service = new AmpViewerAssistance(ampdoc);
expect(service.enabled_).to.be.true;
expect(service.assistanceElement_).to.equal(element);
const sendMessageStub = service.viewer_.sendMessage;
return service.start_().then(() => {
expect(sendMessageStub).to.be.calledOnce;
expect(sendMessageStub.firstCall.args[0]).to
.equal('viewerAssistanceConfig');
expect(sendMessageStub.firstCall.args[1]).to.deep.equal({
'config': config,
});
});
});

it('should send updateActionState to the viewer', () => {
const config = {
'providerId': 'foo-bar',
};
element.textContent = JSON.stringify(config);
const service = new AmpViewerAssistance(ampdoc);
const sendMessageStub = service.viewer_.sendMessageAwaitResponse;
const invocationArgs = {
'foo': 'bar',
};
return service.start_().then(() => {
sendMessageStub.resetHistory();
const invocation = new ActionInvocation(
element, 'updateActionState', invocationArgs);
service.actionHandler_(invocation);
expect(sendMessageStub).to.be.calledOnce;
expect(sendMessageStub.firstCall.args[0]).to.equal('updateActionState');
expect(sendMessageStub.firstCall.args[1]).to.deep.equal(invocationArgs);
});
});

it('should fail to send updateActionState if args are missing', () => {
const config = {
'providerId': 'foo-bar',
};
element.textContent = JSON.stringify(config);
const service = new AmpViewerAssistance(ampdoc);
const sendMessageStub = service.viewer_.sendMessage;
return service.start_().then(() => {
sendMessageStub.reset();
const invocation = new ActionInvocation(element, 'updateActionState');
service.actionHandler_(invocation);
expect(sendMessageStub).to.not.be.called;
});
});

it('should send handle the signIn action', () => {
const config = {
'providerId': 'foo-bar',
};
element.textContent = JSON.stringify(config);
const service = new AmpViewerAssistance(ampdoc);
const sendMessageStub = service.viewer_.sendMessageAwaitResponse;
return service.start_().then(() => {
sendMessageStub.resetHistory();
sendMessageStub.returns(Promise.reject());
const invocation = new ActionInvocation(element, 'signIn');
service.actionHandler_(invocation);
expect(sendMessageStub).to.be.calledOnce;
expect(sendMessageStub.firstCall.args[0]).to.equal('requestSignIn');
expect(sendMessageStub.firstCall.args[1]).to.deep.equal({
providers: ['actions-on-google-gsi'],
});
});
});

it('should make IDENTITY_TOKEN available through a promise', () => {
const config = {
'providerId': 'foo-bar',
};
element.textContent = JSON.stringify(config);
const service = new AmpViewerAssistance(ampdoc);
return service.start_()
.then(() => service.getIdTokenPromise())
.then(token => expect(token).to.equal('idToken'));
});

it('should set a css class if IDENTITY_TOKEN is available', () => {
const config = {
'providerId': 'foo-bar',
};
element.textContent = JSON.stringify(config);
const service = new AmpViewerAssistance(ampdoc);
service.vsync_ = {
mutate: callback => {
callback();
},
};
const sendMessageStub = service.viewer_.sendMessageAwaitResponse;
sendMessageStub.returns(Promise.resolve('idToken'));
return service.getIdTokenPromise().then(() => {
expect(sendMessageStub).to.be.calledOnce;
expect(sendMessageStub.firstCall.args[0]).to.equal(
'getAccessTokenPassive');
expect(sendMessageStub.firstCall.args[1]).to.deep.equal({
providers: ['actions-on-google-gsi'],
});
expect(document.documentElement).to.have.class(
'amp-viewer-assistance-identity-available');
});
});
});