Skip to content

Commit

Permalink
adapt code from ampproject#8897
Browse files Browse the repository at this point in the history
  • Loading branch information
William Chou committed Jun 5, 2017
1 parent 108a73b commit f38af51
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 64 deletions.
88 changes: 64 additions & 24 deletions extensions/amp-bind/0.1/amp-state.js
Expand Up @@ -16,13 +16,26 @@

import {bindForDoc, viewerForDoc} from '../../../src/services';
import {fetchBatchedJsonFor} from '../../../src/batched-json';
import {getMode} from '../../../src/mode';
import {isBindEnabledFor} from './bind-impl';
import {isJsonScriptTag} from '../../../src/dom';
import {toggle} from '../../../src/style';
import {tryParseJson} from '../../../src/json';
import {dev, user} from '../../../src/log';

export class AmpState extends AMP.BaseElement {

/** @param {!AmpElement} element */
constructor(element) {
super(element);

if (getMode().test) {
/** @visibleForTesting {Promise} */
this.updateStatePromise = null;
}
}


/** @override */
getPriority() {
// Loads after other content.
Expand Down Expand Up @@ -71,7 +84,10 @@ export class AmpState extends AMP.BaseElement {
}
const src = mutations['src'];
if (src !== undefined) {
this.fetchSrcAndUpdateState_(/* isInit */ false);
const promise = this.fetchSrcAndUpdateState_(/* isInit */ false);
if (getMode().test) {
this.updateStatePromise = promise;
}
}
}

Expand All @@ -85,38 +101,62 @@ export class AmpState extends AMP.BaseElement {
initialize_() {
const TAG = this.getName_();

// Fetch JSON from endpoint at `src` attribute if it exists,
// otherwise parse child script tag.
// Parse child script tag and/or fetch JSON from endpoint at `src`
// attribute, with the latter taking priority.
const children = this.element.children;
if (children.length > 0) {
this.parseChildAndUpdateState_();
}
if (this.element.hasAttribute('src')) {
this.fetchSrcAndUpdateState_(/* isInit */ true);
if (this.element.children.length > 0) {
user().error(TAG, 'Should not have children if src attribute exists.');
}
} else {
const children = this.element.children;
if (children.length == 1) {
const firstChild = children[0];
if (isJsonScriptTag(firstChild)) {
const json = tryParseJson(firstChild.textContent, e => {
user().error(TAG, 'Failed to parse state. Is it valid JSON?', e);
});
this.updateState_(json, /* isInit */ true);
} else {
user().error(TAG,
'State should be in a <script> tag with type="application/json"');
}
} else if (children.length > 1) {
user().error(TAG, 'Should contain only one <script> child.');
const promise = this.fetchSrcAndUpdateState_(/* isInit */ true);
if (getMode().test) {
this.updateStatePromise = promise;
}
}
}

/**
* Parses JSON in child script element and updates state.
* @private
*/
parseChildAndUpdateState_() {
const TAG = this.getName_();
const children = this.element.children;
if (children.length != 1) {
user().error(TAG, 'Should contain exactly one <script> child.');
return;
}
const firstChild = children[0];
if (!isJsonScriptTag(firstChild)) {
user().error(TAG,
'State should be in a <script> tag with type="application/json".');
return;
}
const json = tryParseJson(firstChild.textContent, e => {
user().error(TAG, 'Failed to parse state. Is it valid JSON?', e);
});
this.updateState_(json, /* isInit */ true);
}

/**
* Wrapper to stub during testing.
* @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
* @param {!Element} element
* @return {!Promise}
* @visibleForTesting
*/
fetchBatchedJsonFor_(ampdoc, element) {
return fetchBatchedJsonFor(ampdoc, element);
}

/**
* @param {boolean} isInit
* @returm {!Promise}
* @private
*/
fetchSrcAndUpdateState_(isInit) {
fetchBatchedJsonFor(this.getAmpDoc(), this.element).then(json => {
const ampdoc = this.getAmpDoc();
return this.fetchBatchedJsonFor_(ampdoc, this.element).then(json => {
this.updateState_(json, isInit);
});
}
Expand All @@ -141,7 +181,7 @@ export class AmpState extends AMP.BaseElement {

/**
* @return {string} Returns a string to identify this tag. May not be unique
* if the element id is not unique.
* if the element id is not unique.
* @private
*/
getName_() {
Expand Down
64 changes: 48 additions & 16 deletions extensions/amp-bind/0.1/test/test-amp-state.js
Expand Up @@ -24,7 +24,8 @@ describes.realWin('AmpState', {
},
}, env => {
let ampState;
let fetchStub;
let fetchSpy;
let batchedJsonStub;
let updateStub;

// Viewer-related vars.
Expand All @@ -41,51 +42,78 @@ describes.realWin('AmpState', {

beforeEach(() => {
viewer = viewerForDoc(env.win.document);

whenFirstVisiblePromise = new Promise(resolve => {
whenFirstVisiblePromiseResolve = resolve;
});
env.sandbox.stub(viewer, 'whenFirstVisible', () => whenFirstVisiblePromise);

ampState = getAmpState();

const impl = ampState.implementation_;

// For simpler testing, stub the fetching and update call to Bind service.
// - `fetchStub should only be called when fetching remote JSON data
// - `updateStub` should only be called when parsing a child script
fetchStub = sandbox.stub(impl, 'fetchSrcAndUpdateState_');
updateStub = sandbox.stub(impl, 'updateState_');
fetchSpy = env.sandbox.spy(impl, 'fetchSrcAndUpdateState_');
updateStub = env.sandbox.stub(impl, 'updateState_');
batchedJsonStub = env.sandbox.stub(impl, 'fetchBatchedJsonFor_');
batchedJsonStub.returns(Promise.resolve({baz: 'qux'}));
});

/** @return {Promise} */
function whenUpdateState() {
return ampState.implementation_.updateStatePromise;
}

it('should fetch json if `src` attribute exists', () => {
ampState.setAttribute('src', 'https://foo.com/bar?baz=1');
ampState.build();

// IMPORTANT: No CORS fetch should happen until viewer is visible.
expect(fetchStub).to.not.have.been.called;
expect(fetchSpy).to.not.have.been.called;
expect(batchedJsonStub).to.not.have.been.called;
expect(updateStub).to.not.have.been.called;

whenFirstVisiblePromiseResolve();
return whenFirstVisiblePromise.then(() => {
expect(fetchStub).calledWithExactly(/* isInit */ true);
expect(updateStub).to.not.have.been.called;
expect(fetchSpy).calledWithExactly(/* isInit */ true);
return whenUpdateState();
}).then(() => {
expect(updateStub).calledWithMatch({baz: 'qux'});
});
});

it('should parse its child script if `src` attribute does not exist', () => {
it('should parse its child script', () => {
ampState.innerHTML = '<script type="application/json">' +
'{"foo": "bar"}</script>';
ampState.build();

// IMPORTANT: No parsing should happen until viewer is visible.
expect(fetchStub).to.not.have.been.called;
expect(fetchSpy).to.not.have.been.called;
expect(batchedJsonStub).to.not.have.been.called;
expect(updateStub).to.not.have.been.called;

whenFirstVisiblePromiseResolve();
return whenFirstVisiblePromise.then(() => {
expect(fetchSpy).to.not.have.been.called;
expect(updateStub).calledWithMatch({foo: 'bar'});
});
});

it('should parse child and fetch `src` if both provided', () => {
ampState.innerHTML = '<script type="application/json">' +
'{"foo": "bar"}</script>';
ampState.setAttribute('src', 'https://foo.com/bar?baz=1');
ampState.build();

// IMPORTANT: No fetching or parsing should happen until viewer is visible.
expect(fetchSpy).to.not.have.been.called;
expect(batchedJsonStub).to.not.have.been.called;
expect(updateStub).to.not.have.been.called;

whenFirstVisiblePromiseResolve();
return whenFirstVisiblePromise.then(() => {
expect(fetchStub).to.not.have.been.called;
expect(updateStub).calledWithMatch({foo: 'bar'});
expect(fetchSpy).calledWithExactly(/* isInit */ true);
return whenUpdateState();
}).then(() => {
expect(updateStub).calledWithMatch({baz: 'qux'});
});
});

Expand All @@ -97,10 +125,14 @@ describes.realWin('AmpState', {
const isVisibleStub = env.sandbox.stub(viewer, 'isVisible');
isVisibleStub.returns(false);
ampState.mutatedAttributesCallback({src: 'https://foo.com/bar?baz=1'});
expect(fetchStub).to.not.have.been.called;
expect(fetchSpy).to.not.have.been.called;
expect(batchedJsonStub).to.not.have.been.called;

isVisibleStub.returns(true);
ampState.mutatedAttributesCallback({src: 'https://foo.com/bar?baz=1'});
expect(fetchStub).calledWithExactly(/* isInit */ false);
expect(fetchSpy).calledWithExactly(/* isInit */ false);
return whenUpdateState().then(() => {
expect(updateStub).calledWithMatch({baz: 'qux'});
});
});
});
31 changes: 29 additions & 2 deletions extensions/amp-bind/0.1/test/validator-amp-bind.html
Expand Up @@ -51,10 +51,37 @@ <h2>Basic example</h2>
<button on="tap:AMP.setState({'foo': 'foo', 'isButtonDisabled': true, 'textClass': 'redBackground', 'imgSrc': 'https://ampbyexample.com/img/Shetland_Sheepdog.jpg', 'imgSize': 200, 'imgAlt': 'Sheepdog'})">Click me</button>
</div>

<amp-state id="myState">
<!-- amp-state with child script -->
<amp-state id="withChildScript">
<script type="application/json">
{
"myStateKey1": "myStateValue1"
"foo": "bar"
}
</script>
</amp-state>

<!-- amp-state with `src` attr -->
<amp-state id="withSrc" src="https://www.foo.com/data.json">
</amp-state>

<!-- amp-state with `src` and `credentials` attrs -->
<amp-state id="withSrcPlusCredentials" src="https://www.foo.com/data.json" credentials="omit">
</amp-state>

<!-- amp-state with child script and `src` attr -->
<amp-state id="withChildScriptAndSrc" src="https://www.foo.com/data.json">
<script type="application/json">
{
"foo": "bar"
}
</script>
</amp-state>

<!-- amp-state with child script and `src` and `credentials` attrs -->
<amp-state id="withChildScriptAndSrcPlusCredentials" src="https://www.foo.com/data.json" credentials="include">
<script type="application/json">
{
"foo": "bar"
}
</script>
</amp-state>
Expand Down
26 changes: 4 additions & 22 deletions extensions/amp-bind/0.1/validator-amp-bind.protoascii
Expand Up @@ -29,7 +29,6 @@ tags: { # <amp-state> (json)
tag_name: "SCRIPT"
spec_name: "amp-bind extension .json script"
mandatory_parent: "AMP-STATE"
satisfies: "amp-bind extension .json script"
requires: "amp-bind extension .js script"
attrs: { name: "nonce" }
attrs: {
Expand All @@ -46,39 +45,22 @@ tags: { # <amp-state> (json)
}
spec_url: "https://www.ampproject.org/docs/reference/components/amp-bind"
}
tags: { # <amp-state> with json child
tags: { # <amp-state>
html_format: AMP
tag_name: "AMP-STATE"
spec_name: "amp-state"
requires: "amp-bind extension .js script"
requires: "amp-bind extension .json script"
disallowed_ancestor: "AMP-SIDEBAR"
attrs: {
name: "id"
mandatory: true
}
# <amp-bind>
attrs: { name: "[src]" }
child_tags: {
mandatory_num_child_tags: 1
first_child_tag_name_oneof: "SCRIPT"
name: "credentials"
also_requires_attr: "src"
}
spec_url: "https://www.ampproject.org/docs/reference/components/amp-bind"
}
tags: { # <amp-state> with src
html_format: AMP
tag_name: "AMP-STATE"
spec_name: "amp-state[src]"
requires: "amp-bind extension .js script"
disallowed_ancestor: "AMP-SIDEBAR"
attrs: {
name: "id"
mandatory: true
}
attrs: { name: "credentials" }
attrs: {
name: "src"
mandatory: true
value_url: {
allowed_protocol: "https"
allow_relative: true # Will be set to false at a future date.
Expand All @@ -88,7 +70,7 @@ tags: { # <amp-state> with src
# <amp-bind>
attrs: { name: "[src]" }
child_tags: {
mandatory_num_child_tags: 0
first_child_tag_name_oneof: "SCRIPT"
}
spec_url: "https://www.ampproject.org/docs/reference/components/amp-bind"
}

0 comments on commit f38af51

Please sign in to comment.