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
Changes from 1 commit
c6724fd
2ba113b
dbb5d5e
8a99fa2
619a444
3c134ac
faba3f3
2ce0926
b537af0
4af6bec
1905b42
360c6a5
b78226d
1bb8e77
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
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')) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this intended to be enabled eventually? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, we usually use 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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_(); | ||
}); | ||
}); |
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 @@ | ||
<!-- | ||
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> |
There was a problem hiding this comment.
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. Seebase-element.js#registerAction
.Then you can also remove the whitelisting in
presubmit-checks.js
.There was a problem hiding this comment.
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.