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
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
171 changes: 171 additions & 0 deletions extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js
@@ -0,0 +1,171 @@
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');

/** @const @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} */
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(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
*/
start_() {
if (!this.enabled_) {
user().info(
TAG, 'Invalid AMP Action - no "id=amp-viewer-assistance" element');
hellokoji marked this conversation as resolved.
Show resolved Hide resolved
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().info(TAG, 'Disabling AMP Action since viewer is not trusted');
hellokoji marked this conversation as resolved.
Show resolved Hide resolved
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<string>}
*/
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));
hellokoji marked this conversation as resolved.
Show resolved Hide resolved
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);
}
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
this.toggleTopClass_(
'amp-viewer-assistance-identity-unavailable', !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,168 @@
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 the css classes if IDENTITY_TOKEN is unavailable', () => {
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.reject());
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).not.to.have.class(
'amp-viewer-assistance-identity-available');
expect(document.documentElement).to.have.class(
'amp-viewer-assistance-identity-unavailable');
});
});
});
@@ -0,0 +1,38 @@
<!--
hellokoji marked this conversation as resolved.
Show resolved Hide resolved
Copyright 2015 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.
-->
<!--
Test Description:
This tests for amp-viewer-assistance syntax.
-->
<!doctype html>
<html ⚡>
<head>
<meta charset="utf-8">
<link rel="canonical" href="./regular-html-version.html">
<meta name="viewport" content="width=device-width,minimum-scale=1">
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<script async src="https://cdn.ampproject.org/v0.js"></script>
<script async custom-element="amp-viewer-assistance" src="https://cdn.ampproject.org/v0/amp-viewer-assistance-0.1.js"></script>
<script id="amp-viewer-assistance" type="application/json">
{
"contents": "currently untested"
}
</script>
</head>
<body>
Hello, world.
</body>
</html>