Skip to content

Commit

Permalink
Make share-tracking extension to get incoming/outcoming fragments (am…
Browse files Browse the repository at this point in the history
…pproject#4019)

* `AmpShareTracking` will get incoming and outgoing share tracking identifier
* Get incoming fragment: from url hash directly or ask viewer to get it
* Get outgoing fragment: from url endpoint provided by vendor or generate randomly
  • Loading branch information
muxin authored and ariangibson committed Sep 7, 2016
1 parent 5ee5301 commit 38ba06a
Show file tree
Hide file tree
Showing 9 changed files with 387 additions and 25 deletions.
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'>
</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) {
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 '';
}, error => {
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);
}
}

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

0 comments on commit 38ba06a

Please sign in to comment.