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 9 commits
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,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) { | ||
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')) { | ||
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().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_(); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
}); | ||
}); | ||
}); |
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.