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

Make share-tracking extension to get incoming/outcoming fragments #4019

Merged
merged 10 commits into from
Jul 28, 2016
8 changes: 8 additions & 0 deletions build-system/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ app.use('/form/echo-json/post', function(req, res) {
});
});

app.use('/share-tracking/get-outgoing-fragment', function(req, res) {
res.setHeader('AMP-Access-Control-Allow-Source-Origin',
req.protocol + '://' + req.headers.host);
res.json({
fragment: '54321'
});
});

// Fetches an AMP document from the AMP proxy and replaces JS
// URLs, so that they point to localhost.
function proxyToAmpProxy(req, res, minify) {
Expand Down
7 changes: 7 additions & 0 deletions css/amp.css
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,10 @@ form [submit-error] {
amp-live-list > [update] {
display: none;
}

/**
* Display none elements
*/
amp-experiment, amp-share-tracking {
display: none;
}
18 changes: 18 additions & 0 deletions examples/share-tracking-with-url.amp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html >
<head>
<meta charset="utf-8">
<title>Share tracking examples</title>
<link rel="canonical" href="amps.html" >
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<link href='https://fonts.googleapis.com/css?family=Questrial' rel='stylesheet' type='text/css'>
<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 src="https://cdn.ampproject.org/v0/amp-share-tracking-0.1.js"></script>
</head>
<body>
<amp-share-tracking data-href='http://localhost:8000/share-tracking/get-outgoing-fragment'>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there no layout=nodisplay?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I return true in isLayoutSupported. So I don't need layout=nodisplay. What's the correct way?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not enough. It's best to declare layout=nodisplay. Alternatively, we can add amp-share-tracking {display: none;} in main CSS (amp.css). I can be talked into it.

Copy link
Contributor Author

@muxin muxin Jul 26, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

amp-analytics and amp-experiment both return true in isLayoutSupported without display: none in amp.css nor layout=nodisplay in the html element. Shall I change those two?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added display: none in amp.css

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isLayoutSupported can only allow nodisplay and container for both elements.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the isLayoutSupported in the latest commit

</amp-share-tracking>

</body>
</html>
18 changes: 18 additions & 0 deletions examples/share-tracking-without-url.amp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html >
<head>
<meta charset="utf-8">
<title>Share tracking examples</title>
<link rel="canonical" href="amps.html" >
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<link href='https://fonts.googleapis.com/css?family=Questrial' rel='stylesheet' type='text/css'>
<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 src="https://cdn.ampproject.org/v0/amp-share-tracking-0.1.js"></script>
</head>
<body>
<amp-share-tracking>
</amp-share-tracking>

</body>
</html>
120 changes: 95 additions & 25 deletions extensions/amp-share-tracking/0.1/amp-share-tracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,113 @@
*/

import {isExperimentOn} from '../../../src/experiments';
import {getService} from '../../../src/service';
import {dev} from '../../../src/log';
import {xhrFor} from '../../../src/xhr';
import {viewerFor} from '../../../src/viewer';
import {Layout} from '../../../src/layout';
import {dev, user} from '../../../src/log';

/** @private @const {string} */
const TAG = 'amp-share-tracking';

class AmpShareTracking extends AMP.BaseElement {
/**
* @visibleForTesting
*/
export class AmpShareTracking extends AMP.BaseElement {
/**
* @return {boolean}
* @private
*/
isExperimentOn_() {
return isExperimentOn(this.win, TAG);
}

/** @override */
isLayoutSupported(layout) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind copying this to amp-experiment.js as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a separate PR since this has nothing to do with share-tracking.

return layout == Layout.NODISPLAY || layout == Layout.CONTAINER;
}

/** @override */
buildCallback() {
this.isExperimentOn_ = isExperimentOn(this.win, TAG);
if (!this.isExperimentOn_) {
dev.warn(TAG, `TAG ${TAG} disabled`);
return;
user.assert(this.isExperimentOn_(), `${TAG} experiment is disabled`);

/** @private {string} */
this.vendorHref_ = this.element.getAttribute('data-href');
dev.fine(TAG, 'vendorHref_: ', this.vendorHref_);

/** @private {!Promise<!Object>} */
this.shareTrackingFragments_ = Promise.all([
this.getIncomingFragment_(),
this.getOutgoingFragment_()]).then(results => {
dev.fine(TAG, 'incomingFragment: ', results[0]);
dev.fine(TAG, 'outgoingFragment: ', results[1]);
return {
incomingFragment: results[0],
outgoingFragment: results[1],
};
});
}

/**
* Get the incoming share-tracking fragment from the viewer
* @return {!Promise<string>}
* @private
*/
getIncomingFragment_() {
return viewerFor(this.win).getFragment().then(fragment => {
const match = fragment.match(/\.([^&]*)/);
return match ? match[1] : '';
});
}

/**
* Get an outgoing share-tracking fragment
* @return {!Promise<string>}
* @private
*/
getOutgoingFragment_() {
if (this.vendorHref_) {
return this.getOutgoingFragmentFromVendor_(this.vendorHref_);
}
return this.getOutgoingRandomFragment_();
}
}

/**
* ShareTrackingService processes the incoming and outcoming url fragment
*/
export class ShareTrackingService {
constructor(window) {
this.win = window;
/**
* Get an outgoing share-tracking fragment from vendor
* by issueing a post request to the url the vendor provided
* @param {string} vendorUrl
* @return {!Promise<string>}
* @private
*/
getOutgoingFragmentFromVendor_(vendorUrl) {
const postReq = {
method: 'POST',
credentials: 'include',
requireAmpResponseSourceOrigin: true,
body: {},
};
return xhrFor(this.win).fetchJson(vendorUrl, postReq).then(response => {
if (response.fragment) {
return response.fragment;
}
user.error(TAG, 'The response from [' + vendorUrl + '] does not ' +
'have a fragment value.');
return '';
}).catch(error => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: This callback can be the second param to #then above.

user.error(TAG, 'The request to share-tracking endpoint failed:' + error);
return '';
});
}
}

/**
* @param {!Window} window
* @return {!ShareTrackingService}
*/
export function installShareTrackingService(window) {
return getService(window, 'shareTrackingService', () => {
return new ShareTrackingService(window);
});
/**
* Get a random outgoing share-tracking fragment
* @return {!Promise<string>}
* @private
*/
getOutgoingRandomFragment_() {
// TODO(yuxichen): Generate random outgoing fragment
const randomFragment = 'rAmDoM';
return Promise.resolve(randomFragment);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couldn't we just return {string} here w/o having to do Promise.resolve. the only usage i see is this being passed into Promise.all

cc @jridgewell

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

although this would change the return type of getOutgoingFragment_ to Promise<string>|string which might make the API more complex

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer making the return type of getOutgoingFragment_ simple, otherwise I would make the method that calls it confused.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think returning a promise here is better, since #getOutgoingFragmentFromVendor_ is returning one (and has to).

}
}

installShareTrackingService(AMP.win);

AMP.registerElement('amp-share-tracking', AmpShareTracking);
168 changes: 168 additions & 0 deletions extensions/amp-share-tracking/0.1/test/test-amp-share-tracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Copyright 2016 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 {createIframePromise} from '../../../../testing/iframe';
import {AmpShareTracking} from '../amp-share-tracking';
import {Viewer} from '../../../../src/service/viewer-impl';
import {Xhr} from '../../../../src/service/xhr-impl';
import {toggleExperiment} from '../../../../src/experiments';
import * as sinon from 'sinon';

describe('amp-share-tracking', () => {
let sandbox;
let viewerForMock;
let xhrMock;

beforeEach(() => {
sandbox = sinon.sandbox.create();
viewerForMock = sandbox.stub(Viewer.prototype, 'getFragment');
xhrMock = sandbox.stub(Xhr.prototype, 'fetchJson');
});

afterEach(() => {
sandbox.restore();
});

function getAmpShareTracking(optVendorUrl) {
return createIframePromise().then(iframe => {
toggleExperiment(iframe.win, 'amp-share-tracking', true);
const el = iframe.doc.createElement('amp-share-tracking');
if (optVendorUrl) {
el.setAttribute('data-href', optVendorUrl);
}
const ampShareTracking = new AmpShareTracking(el);
ampShareTracking.buildCallback();
return ampShareTracking;
});
}

it('should get incoming fragment starting with dot', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('.12345'));
const mockJsonResponse = {fragment: '54321'};
xhrMock.onFirstCall().returns(Promise.resolve(mockJsonResponse));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.incomingFragment).to.equal('12345');
});
});
});

it('should get incoming fragment starting with dot and ignore ' +
'other parameters', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('.12345&key=value'));
const mockJsonResponse = {fragment: '54321'};
xhrMock.onFirstCall().returns(Promise.resolve(mockJsonResponse));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.incomingFragment).to.equal('12345');
});
});
});

it('should ignore incoming fragment if it is empty', () => {
viewerForMock.onFirstCall().returns(Promise.resolve(''));
const mockJsonResponse = {fragment: '54321'};
xhrMock.onFirstCall().returns(Promise.resolve(mockJsonResponse));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.incomingFragment).to.equal('');
});
});
});

it('should ignore incoming fragment if it does not start with dot', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('12345'));
const mockJsonResponse = {fragment: '54321'};
xhrMock.onFirstCall().returns(Promise.resolve(mockJsonResponse));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.incomingFragment).to.equal('');
});
});
});

it('should get outgoing fragment randomly if no vendor url ' +
'is provided', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('.12345'));
return getAmpShareTracking().then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.be.null;
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.outgoingFragment).to.equal('rAmDoM');
});
});
});

it('should get outgoing fragment from vendor if vendor url is provided ' +
'and the response format is correct', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('.12345'));
const mockJsonResponse = {fragment: '54321'};
xhrMock.onFirstCall().returns(Promise.resolve(mockJsonResponse));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.outgoingFragment).to.equal('54321');
});
});
});

it('should get empty outgoing fragment if vendor url is provided ' +
'but the response format is NOT correct', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('.12345'));
const mockJsonResponse = {foo: 'bar'};
xhrMock.onFirstCall().returns(Promise.resolve(mockJsonResponse));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.outgoingFragment).to.equal('');
});
});
});

it('should call fetchJson with correct request when getting outgoing' +
'fragment', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('.12345'));
const mockJsonResponse = {fragment: '54321'};
xhrMock.onFirstCall().returns(Promise.resolve(mockJsonResponse));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
const xhrCall = xhrMock.getCall(0);
expect(xhrCall.args[0]).to.equal('http://foo.bar');
const config = xhrCall.args[1];
expect(config.method).to.equal('POST');
expect(config.credentials).to.equal('include');
expect(config.requireAmpResponseSourceOrigin).to.be.true;
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.outgoingFragment).to.equal('54321');
});
});
});

it('should get empty outgoing fragment if vendor url is provided ' +
'but the xhr fails', () => {
viewerForMock.onFirstCall().returns(Promise.resolve('.12345'));
xhrMock.onFirstCall().returns(Promise.reject('404'));
return getAmpShareTracking('http://foo.bar').then(ampShareTracking => {
expect(ampShareTracking.vendorHref_).to.equal('http://foo.bar');
return ampShareTracking.shareTrackingFragments_.then(fragments => {
expect(fragments.outgoingFragment).to.equal('');
});
});
});
});
2 changes: 2 additions & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ function buildExamples(watch) {
buildExample('released.amp.html');
buildExample('social-share.amp.html');
buildExample('twitter.amp.html');
buildExample('share-tracking-with-url.amp.html');
buildExample('share-tracking-without-url.amp.html');
buildExample('soundcloud.amp.html');
buildExample('springboard-player.amp.html');
buildExample('sticky.ads.amp.html');
Expand Down