Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(79)

Side by Side Diff: chrome/test/data/extensions/platform_apps/web_view/shim/main.js

Issue 959413003: Implement <webview>.addContentScript/removeContentScript API [1] (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Created 5 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved. 1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be 2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file. 3 // found in the LICENSE file.
4 4
5 var util = {}; 5 var util = {};
6 var embedder = {}; 6 var embedder = {};
7 embedder.baseGuestURL = ''; 7 embedder.baseGuestURL = '';
8 embedder.emptyGuestURL = ''; 8 embedder.emptyGuestURL = '';
9 embedder.windowOpenGuestURL = ''; 9 embedder.windowOpenGuestURL = '';
10 embedder.noReferrerGuestURL = ''; 10 embedder.noReferrerGuestURL = '';
11 embedder.redirectGuestURL = ''; 11 embedder.redirectGuestURL = '';
12 embedder.redirectGuestURLDest = ''; 12 embedder.redirectGuestURLDest = '';
13 embedder.closeSocketURL = ''; 13 embedder.closeSocketURL = '';
14 embedder.tests = {}; 14 embedder.tests = {};
15 15
16 var request_to_comm_channel_1 = 'connect';
17 var request_to_comm_channel_2 = 'connect_request';
18 var response_from_comm_channel_1 = 'connected';
19 var response_from_comm_channel_2 = 'connected_response';
20
16 embedder.setUp_ = function(config) { 21 embedder.setUp_ = function(config) {
17 if (!config || !config.testServer) { 22 if (!config || !config.testServer) {
18 return; 23 return;
19 } 24 }
20 embedder.baseGuestURL = 'http://localhost:' + config.testServer.port; 25 embedder.baseGuestURL = 'http://localhost:' + config.testServer.port;
21 embedder.emptyGuestURL = embedder.baseGuestURL + 26 embedder.emptyGuestURL = embedder.baseGuestURL +
22 '/extensions/platform_apps/web_view/shim/empty_guest.html'; 27 '/extensions/platform_apps/web_view/shim/empty_guest.html';
23 embedder.windowOpenGuestURL = embedder.baseGuestURL + 28 embedder.windowOpenGuestURL = embedder.baseGuestURL +
24 '/extensions/platform_apps/web_view/shim/guest.html'; 29 '/extensions/platform_apps/web_view/shim/guest.html';
30 embedder.windowOpenGuestFromSameURL = embedder.baseGuestURL +
31 '/extensions/platform_apps/web_view/shim/guest_from_opener.html';
25 embedder.noReferrerGuestURL = embedder.baseGuestURL + 32 embedder.noReferrerGuestURL = embedder.baseGuestURL +
26 '/extensions/platform_apps/web_view/shim/guest_noreferrer.html'; 33 '/extensions/platform_apps/web_view/shim/guest_noreferrer.html';
27 embedder.detectUserAgentURL = embedder.baseGuestURL + '/detect-user-agent'; 34 embedder.detectUserAgentURL = embedder.baseGuestURL + '/detect-user-agent';
28 embedder.redirectGuestURL = embedder.baseGuestURL + '/server-redirect'; 35 embedder.redirectGuestURL = embedder.baseGuestURL + '/server-redirect';
29 embedder.redirectGuestURLDest = embedder.baseGuestURL + 36 embedder.redirectGuestURLDest = embedder.baseGuestURL +
30 '/extensions/platform_apps/web_view/shim/guest_redirect.html'; 37 '/extensions/platform_apps/web_view/shim/guest_redirect.html';
31 embedder.closeSocketURL = embedder.baseGuestURL + '/close-socket'; 38 embedder.closeSocketURL = embedder.baseGuestURL + '/close-socket';
32 embedder.testImageBaseURL = embedder.baseGuestURL + 39 embedder.testImageBaseURL = embedder.baseGuestURL +
33 '/extensions/platform_apps/web_view/shim/'; 40 '/extensions/platform_apps/web_view/shim/';
34 embedder.virtualURL = 'http://virtualurl/'; 41 embedder.virtualURL = 'http://virtualurl/';
(...skipping 707 matching lines...) Expand 10 before | Expand all | Expand 10 after
742 // Removing after navigation should not change the partition. 749 // Removing after navigation should not change the partition.
743 webview.removeAttribute('partition'); 750 webview.removeAttribute('partition');
744 embedder.test.assertEq('testme', webview.partition); 751 embedder.test.assertEq('testme', webview.partition);
745 embedder.test.succeed(); 752 embedder.test.succeed();
746 }; 753 };
747 webview.addEventListener('loadstop', loadstopHandler); 754 webview.addEventListener('loadstop', loadstopHandler);
748 755
749 webview.setAttribute('src', 'data:text/html,<html><body>guest</body></html>'); 756 webview.setAttribute('src', 'data:text/html,<html><body>guest</body></html>');
750 } 757 }
751 758
759 // This test verifies that a content script will be injected to the webview when
760 // the webview is navigated to a page that matches the URL pattern defined in
761 // the content sript.
762 function testAddContentScript() {
763 var webview = document.createElement('webview');
764
765 console.log("Step 1: call <webview>.addContentScripts.");
766 webview.addContentScripts(
767 [{"name": 'myrule',
768 "matches": ["http://*/extensions/*"],
769 "js": ["inject_comm_channel.js"],
770 "run_at": "document_start"}]);
771
772 webview.addEventListener('loadstop', function() {
773 var msg = [request_to_comm_channel_1];
774 webview.contentWindow.postMessage(JSON.stringify(msg), '*');
775 });
776
777 window.addEventListener('message', function(e) {
778 var data = JSON.parse(e.data);
779 if (data == response_from_comm_channel_1) {
780 console.log(
781 'Step 2: A communication channel has been established with webview.');
782 embedder.test.succeed();
783 return;
784 }
785 console.log('Unexpected message: \'' + data[0] + '\'');
786 embedder.test.fail();
787 });
788
789 webview.src = embedder.emptyGuestURL;
790 document.body.appendChild(webview);
791 }
792
793 // Adds two content scripts with the same URL pattern to <webview> at the same
794 // time. This test verifies that both scripts are injected when the <webview>
795 // navigates to a URL that matches the URL pattern.
796 function testAddMultipleContentScripts() {
797 var webview = document.createElement('webview');
798
799 console.log("Step 1: call <webview>.addContentScripts(myrule1 & myrule2)");
800 webview.addContentScripts(
801 [{"name": 'myrule1',
802 "matches": ["http://*/extensions/*"],
803 "js": ["inject_comm_channel.js"],
804 "run_at": "document_start"},
805 {"name": 'myrule2',
806 "matches": ["http://*/extensions/*"],
807 "js": ["inject_comm_channel_2.js"],
808 "run_at": "document_start"}]);
809
810 webview.addEventListener('loadstop', function() {
811 var msg1 = [request_to_comm_channel_1];
812 webview.contentWindow.postMessage(JSON.stringify(msg1), '*');
813 var msg2 = [request_to_comm_channel_2];
814 webview.contentWindow.postMessage(JSON.stringify(msg2), '*');
815 });
816
817 var response_1 = false;
818 var response_2 = false;
819 window.addEventListener('message', function(e) {
820 var data = JSON.parse(e.data);
821 if (data == response_from_comm_channel_1) {
822 console.log(
823 'Step 2: A communication channel has been established with webview.');
824 response_1 = true;
825 if (response_1 && response_2)
826 embedder.test.succeed();
827 return;
828 } else if (data == response_from_comm_channel_2) {
829 console.log(
830 'Step 3: A communication channel has been established with webview.');
831 response_2 = true;
832 if (response_1 && response_2)
833 embedder.test.succeed();
834 return;
835 }
836 console.log('Unexpected message: \'' + data[0] + '\'');
837 embedder.test.fail();
838 });
839
840 webview.src = embedder.emptyGuestURL;
841 document.body.appendChild(webview);
842 }
843
844 // Adds a content script to <webview> and navigates. After seeing the script is
845 // injected, we add another content script with the same name to the <webview>.
846 // This test verifies that the second script will replace the first one and be
847 // injected after navigating the <webview>. Meanwhile, the <webview> shouldn't
848 // get any message from the first script anymore.
849 function testAddContentScriptWithSameNameShouldOverwriteTheExistingOne() {
850 var webview = document.createElement('webview');
851
852 console.log("Step 1: call <webview>.addContentScripts(myrule1)");
853 webview.addContentScripts(
854 [{"name": 'myrule1',
855 "matches": ["http://*/extensions/*"],
856 "js": ["inject_comm_channel.js"],
857 "run_at": "document_start"}]);
858 var connect_script_1 = true;
859 var connect_script_2 = false;
860
861 webview.addEventListener('loadstop', function() {
862 if (connect_script_1) {
863 var msg1 = [request_to_comm_channel_1];
864 webview.contentWindow.postMessage(JSON.stringify(msg1), '*');
865 connect_script_1 = false;
866 }
867 if (connect_script_2) {
868 var msg2 = [request_to_comm_channel_2];
869 webview.contentWindow.postMessage(JSON.stringify(msg2), '*');
870 connect_script_2 = false;
871 }
872 });
873
874 var should_get_response_from_script_1 = true;
875 window.addEventListener('message', function(e) {
876 var data = JSON.parse(e.data);
877 if (data == response_from_comm_channel_1) {
878 if (should_get_response_from_script_1) {
879 console.log(
880 'Step 2: A communication channel has been established with webview.'
881 );
882 webview.addContentScripts(
883 [{"name": 'myrule1',
884 "matches": ["http://*/extensions/*"],
885 "js": ["inject_comm_channel_2.js"],
886 "run_at": "document_start"}]);
887 connect_script_2 = true;
888 should_get_response_from_script_1 = false;
889 webview.src = embedder.emptyGuestURL;
890 } else {
891 embedder.test.fail();
892 }
893 return;
894 } else if (data == response_from_comm_channel_2) {
895 console.log(
896 'Step 3: Another communication channel has been established ' +
897 'with webview.');
898 setTimeout(function() {
899 embedder.test.succeed();
900 }, 0);
901 return;
902 }
903 console.log('Unexpected message: \'' + data[0] + '\'');
904 embedder.test.fail();
905 });
906
907 webview.src = embedder.emptyGuestURL;
908 document.body.appendChild(webview);
909 }
910
911 // There are two <webview>s are added to the DOM, and we add a content script
912 // to one of them. This test verifies that the script won't be injected in
913 // the other <webview>.
914 function testAddContentScriptToOneWebViewShouldNotInjectToTheOtherWebView() {
915 var webview1 = document.createElement('webview');
916 var webview2 = document.createElement('webview');
917
918 console.log("Step 1: call <webview1>.addContentScripts.");
919 webview1.addContentScripts(
920 [{"name": 'myrule',
921 "matches": ["http://*/extensions/*"],
922 "js": ["inject_comm_channel.js"],
923 "run_at": "document_start"}]);
924
925 webview2.addEventListener('loadstop', function() {
926 console.log("Step 2: webview2 requests to build communication channel.");
927 var msg = [request_to_comm_channel_1];
928 webview2.contentWindow.postMessage(JSON.stringify(msg), '*');
929 setTimeout(function() {
930 embedder.test.succeed();
931 }, 0);
932 });
933
934 window.addEventListener('message', function(e) {
935 var data = JSON.parse(e.data);
936 if (data == response_from_comm_channel_1) {
937 embedder.test.fail();
938 return;
939 }
940 console.log('Unexpected message: \'' + data[0] + '\'');
941 embedder.test.fail();
942 });
943
944 webview1.src = embedder.emptyGuestURL;
945 webview2.src = embedder.emptyGuestURL;
946 document.body.appendChild(webview1);
947 document.body.appendChild(webview2);
948 }
949
950
951 // Adds a content script to <webview> and navigates to a URL that matches the
952 // URL pattern defined in the script. After the first navigation, we remove this
953 // script from the <webview> and navigates to the same URL. This test verifies
954 // taht the script is injected during the first navigation, but isn't injected
955 // after removing it.
956 function testAddAndRemoveContentScripts() {
957 var webview = document.createElement('webview');
958
959 console.log("Step 1: call <webview>.addContentScripts.");
960 webview.addContentScripts(
961 [{"name": 'myrule',
962 "matches": ["http://*/extensions/*"],
963 "js": ["inject_comm_channel.js"],
964 "run_at": "document_start"}]);
965
966 var count = 0;
967 webview.addEventListener('loadstop', function() {
968 if (count == 0) {
969 console.log('Step 2: post message to build connect.');
970 var msg = [request_to_comm_channel_1];
971 webview.contentWindow.postMessage(JSON.stringify(msg), '*');
972 ++count;
973 } else if (count == 1) {
974 console.log(
975 'Step 4: call <webview>.removeContentScripts and navigate.');
976 webview.removeContentScripts();
977 webview.src = embedder.emptyGuestURL;
978 ++count;
979 } else if (count == 2) {
980 console.log('Step 5: post message to build connect again.');
981 var msg = [request_to_comm_channel_1];
982 webview.contentWindow.postMessage(JSON.stringify(msg), '*');
983 setTimeout(function() {
984 embedder.test.succeed();
985 }, 0);
986 }
987 });
988
989 var replyCount = 0;
990 window.addEventListener('message', function(e) {
991 var data = JSON.parse(e.data);
992 if (data[0] == response_from_comm_channel_1) {
993 console.log(
994 'Step 3: A communication channel has been established with webview.');
995 if (replyCount == 0) {
996 webview.setAttribute('src', 'about:blank');
997 ++replyCount;
998 return;
999 } else if (replyCount == 1) {
1000 embedder.test.fail();
1001 return;
1002 }
1003 }
1004 console.log('Unexpected message: \'' + data[0] + '\'');
1005 embedder.test.fail();
1006 });
1007
1008 webview.src = embedder.emptyGuestURL;
1009 document.body.appendChild(webview);
1010 }
1011
1012 // This test verifies that the addContentScripts API works with the new window
1013 // API.
1014 function testAddContentScriptsWithNewWindowAPI() {
1015 var webview = document.createElement('webview');
1016
1017 var newwebview;
1018 webview.addEventListener('newwindow', function(e) {
1019 e.preventDefault();
1020 newwebview = document.createElement('webview');
1021
1022 console.log('Step 2: call newwebview.addContentScripts.');
1023 newwebview.addContentScripts(
1024 [{"name": 'myrule',
1025 "matches": ["http://*/extensions/*"],
1026 "js": ["inject_comm_channel.js"],
1027 "run_at": "document_start"}]);
1028
1029 newwebview.addEventListener('loadstop', function(evt) {
1030 var msg = [request_to_comm_channel_1];
1031 console.log('Step 4: new webview postmessage to build communication ' +
1032 'channel.');
1033 newwebview.contentWindow.postMessage(JSON.stringify(msg), '*');
1034 });
1035
1036 document.body.appendChild(newwebview);
1037 // attach the new window to the new <webview>.
1038 console.log("Step 3: attaches the new webview.");
1039 e.window.attach(newwebview);
1040 });
1041
1042 window.addEventListener('message', function(e) {
1043 var data = JSON.parse(e.data);
1044 if (data == response_from_comm_channel_1 &&
1045 e.source == newwebview.contentWindow) {
1046 console.log('Step 5: a communication channel has been established ' +
1047 'with the new webview.');
1048 embedder.test.succeed();
1049 return;
1050 } else {
1051 embedder.test.fail();
1052 return;
1053 }
1054 console.log('unexpected message: \'' + data[0] + '\'');
1055 embedder.test.fail();
1056 });
1057
1058 console.log('Step 1: navigates the webview to window open guest URL.');
1059 webview.setAttribute('src', embedder.windowOpenGuestFromSameURL);
1060 document.body.appendChild(webview);
1061 }
1062
1063 // Adds a content script to <webview>. This test verifies that the script is
1064 // injected after terminate and reload <webview>.
1065 function testContentScriptIsInjectedAfterTerminateAndReloadWebView() {
1066 var webview = document.createElement('webview');
1067
1068 console.log('Step 1: call <webview>.addContentScripts.');
1069 webview.addContentScripts(
1070 [{"name": 'myrule',
1071 "matches": ["http://*/extensions/*"],
1072 "js": ["inject_comm_channel.js"],
1073 "run_at": "document_start"}]);
1074
1075 var count = 0;
1076 webview.addEventListener('loadstop', function() {
1077 if (count == 0) {
1078 console.log('Step 2: call webview.terminate().');
1079 webview.terminate();
1080 ++count;
1081 return;
1082 } else if (count == 1) {
1083 console.log('Step 4: postMessage to build communication.');
1084 var msg = [request_to_comm_channel_1];
1085 webview.contentWindow.postMessage(JSON.stringify(msg), '*');
1086 ++count;
1087 }
1088 });
1089
1090 webview.addEventListener('exit', function() {
1091 console.log('Step 3: call webview.reload().');
1092 webview.reload();
1093 });
1094
1095 window.addEventListener('message', function(e) {
1096 var data = JSON.parse(e.data);
1097 if (data == response_from_comm_channel_1) {
1098 console.log(
1099 'Step 5: A communication channel has been established with webview.');
1100 embedder.test.succeed();
1101 return;
1102 }
1103 console.log('Unexpected message: \'' + data[0] + '\'');
1104 embedder.test.fail();
1105 });
1106
1107 webview.src = embedder.emptyGuestURL;
1108 document.body.appendChild(webview);
1109 }
1110
1111 // This test verifies the content script won't be removed when the guest is
1112 // destroyed, i.e., removed <webview> from the DOM.
1113 function testContentScriptExistsAsLongAsWebViewTagExists() {
1114 var webview = document.createElement('webview');
1115
1116 console.log('Step 1: call <webview>.addContentScripts.');
1117 webview.addContentScripts(
1118 [{"name": 'myrule',
1119 "matches": ["http://*/extensions/*"],
1120 "js": ["simple_script.js"],
1121 "run_at": "document_end"}]);
1122
1123 var count = 0;
1124 webview.addEventListener('loadstop', function() {
1125 if (count == 0) {
1126 console.log('Step 2: check the result of content script injected.');
1127 webview.executeScript({
1128 code: 'document.body.style.backgroundColor;'
1129 }, function(results) {
1130 embedder.test.assertEq(1, results.length);
1131 embedder.test.assertEq('red', results[0]);
1132 });
1133
1134 console.log('Step 3: remove webview from the DOM.');
1135 document.body.removeChild(webview);
1136
1137 console.log('Step 4: add webview back to the DOM.');
1138 document.body.appendChild(webview);
1139 ++count;
1140 } else if (count == 1) {
1141 webview.executeScript({
1142 code: 'document.body.style.backgroundColor;'
1143 }, function(results) {
1144 console.log('Step 5: check the result of content script injected' +
1145 ' again.');
1146 embedder.test.assertEq(1, results.length);
1147 embedder.test.assertEq('red', results[0]);
1148 embedder.test.succeed();
1149 });
1150 }
1151 });
1152
1153 webview.src = embedder.emptyGuestURL;
1154 document.body.appendChild(webview);
1155 }
1156
752 function testExecuteScriptFail() { 1157 function testExecuteScriptFail() {
753 var webview = document.createElement('webview'); 1158 var webview = document.createElement('webview');
754 document.body.appendChild(webview); 1159 document.body.appendChild(webview);
755 setTimeout(function() { 1160 setTimeout(function() {
756 webview.executeScript( 1161 webview.executeScript(
757 {code:'document.body.style.backgroundColor = "red";'}, 1162 {code:'document.body.style.backgroundColor = "red";'},
758 function(results) { 1163 function(results) {
759 embedder.test.fail(); 1164 embedder.test.fail();
760 }); 1165 });
761 setTimeout(function() { 1166 setTimeout(function() {
(...skipping 1460 matching lines...) Expand 10 before | Expand all | Expand 10 after
2222 'testInvalidChromeExtensionURL': testInvalidChromeExtensionURL, 2627 'testInvalidChromeExtensionURL': testInvalidChromeExtensionURL,
2223 'testWebRequestAPIExistence': testWebRequestAPIExistence, 2628 'testWebRequestAPIExistence': testWebRequestAPIExistence,
2224 'testEventName': testEventName, 2629 'testEventName': testEventName,
2225 'testOnEventProperties': testOnEventProperties, 2630 'testOnEventProperties': testOnEventProperties,
2226 'testLoadProgressEvent': testLoadProgressEvent, 2631 'testLoadProgressEvent': testLoadProgressEvent,
2227 'testDestroyOnEventListener': testDestroyOnEventListener, 2632 'testDestroyOnEventListener': testDestroyOnEventListener,
2228 'testCannotMutateEventName': testCannotMutateEventName, 2633 'testCannotMutateEventName': testCannotMutateEventName,
2229 'testPartitionChangeAfterNavigation': testPartitionChangeAfterNavigation, 2634 'testPartitionChangeAfterNavigation': testPartitionChangeAfterNavigation,
2230 'testPartitionRemovalAfterNavigationFails': 2635 'testPartitionRemovalAfterNavigationFails':
2231 testPartitionRemovalAfterNavigationFails, 2636 testPartitionRemovalAfterNavigationFails,
2637 'testAddContentScript': testAddContentScript,
2638 'testAddMultipleContentScripts': testAddMultipleContentScripts,
2639 'testAddContentScriptWithSameNameShouldOverwriteTheExistingOne':
2640 testAddContentScriptWithSameNameShouldOverwriteTheExistingOne,
2641 'testAddContentScriptToOneWebViewShouldNotInjectToTheOtherWebView':
2642 testAddContentScriptToOneWebViewShouldNotInjectToTheOtherWebView,
2643 'testAddAndRemoveContentScripts': testAddAndRemoveContentScripts,
2644 'testAddContentScriptsWithNewWindowAPI':
2645 testAddContentScriptsWithNewWindowAPI,
2646 'testContentScriptIsInjectedAfterTerminateAndReloadWebView':
2647 testContentScriptIsInjectedAfterTerminateAndReloadWebView,
2648 'testContentScriptExistsAsLongAsWebViewTagExists':
2649 testContentScriptExistsAsLongAsWebViewTagExists,
2232 'testExecuteScriptFail': testExecuteScriptFail, 2650 'testExecuteScriptFail': testExecuteScriptFail,
2233 'testExecuteScript': testExecuteScript, 2651 'testExecuteScript': testExecuteScript,
2234 'testExecuteScriptIsAbortedWhenWebViewSourceIsChanged': 2652 'testExecuteScriptIsAbortedWhenWebViewSourceIsChanged':
2235 testExecuteScriptIsAbortedWhenWebViewSourceIsChanged, 2653 testExecuteScriptIsAbortedWhenWebViewSourceIsChanged,
2236 'testTerminateAfterExit': testTerminateAfterExit, 2654 'testTerminateAfterExit': testTerminateAfterExit,
2237 'testAssignSrcAfterCrash': testAssignSrcAfterCrash, 2655 'testAssignSrcAfterCrash': testAssignSrcAfterCrash,
2238 'testNavOnConsecutiveSrcAttributeChanges': 2656 'testNavOnConsecutiveSrcAttributeChanges':
2239 testNavOnConsecutiveSrcAttributeChanges, 2657 testNavOnConsecutiveSrcAttributeChanges,
2240 'testNavOnSrcAttributeChange': testNavOnSrcAttributeChange, 2658 'testNavOnSrcAttributeChange': testNavOnSrcAttributeChange,
2241 'testReassignSrcAttribute': testReassignSrcAttribute, 2659 'testReassignSrcAttribute': testReassignSrcAttribute,
(...skipping 46 matching lines...) Expand 10 before | Expand all | Expand 10 after
2288 'testPerViewZoomMode': testPerViewZoomMode, 2706 'testPerViewZoomMode': testPerViewZoomMode,
2289 'testDisabledZoomMode': testDisabledZoomMode, 2707 'testDisabledZoomMode': testDisabledZoomMode,
2290 }; 2708 };
2291 2709
2292 onload = function() { 2710 onload = function() {
2293 chrome.test.getConfig(function(config) { 2711 chrome.test.getConfig(function(config) {
2294 embedder.setUp_(config); 2712 embedder.setUp_(config);
2295 chrome.test.sendMessage("Launched"); 2713 chrome.test.sendMessage("Launched");
2296 }); 2714 });
2297 }; 2715 };
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698