Skip to content

Commit

Permalink
Add amp-viewer-assistance extension (ampproject#15195).
Browse files Browse the repository at this point in the history
Change-Id: I78dac3ec9f85c3bbd1ceb4ce88a61b3f7d0dc539
  • Loading branch information
hellokoji committed Feb 19, 2019
1 parent 2dc43d8 commit 2500a94
Show file tree
Hide file tree
Showing 13 changed files with 528 additions and 0 deletions.
2 changes: 2 additions & 0 deletions build-system/tasks/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,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
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,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
Original file line number Diff line number Diff line change
@@ -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) {
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
*/
start_() {
if (!this.enabled_) {
user().info(
TAG, 'Invalid AMP Action - no "id=amp-viewer-assistance" element');
return this;
}
return this.viewer_.isTrustedViewer().then(isTrustedViewer => {
if (!isTrustedViewer &&
!isExperimentOn(this.ampdoc_.win, 'amp-viewer-assistance-untrusted')) {
this.enabled_ = false;
user().info(TAG, 'Disabling AMP Action since viewer is not trusted');
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));
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);
}
});
}

/**
* 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);
this.toggleTopClass_(
'amp-viewer-assistance-identity-unavailable', !available);
}

/**
* 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_();
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!--
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>

0 comments on commit 2500a94

Please sign in to comment.