|
1 /* Any copyright is dedicated to the Public Domain. |
|
2 * http://creativecommons.org/publicdomain/zero/1.0/ |
|
3 */ |
|
4 |
|
5 XPCOMUtils.defineLazyModuleGetter(this, "Promise", |
|
6 "resource://gre/modules/Promise.jsm"); |
|
7 XPCOMUtils.defineLazyModuleGetter(this, "Task", |
|
8 "resource://gre/modules/Task.jsm"); |
|
9 XPCOMUtils.defineLazyModuleGetter(this, "AboutHomeUtils", |
|
10 "resource:///modules/AboutHome.jsm"); |
|
11 |
|
12 let gRightsVersion = Services.prefs.getIntPref("browser.rights.version"); |
|
13 |
|
14 registerCleanupFunction(function() { |
|
15 // Ensure we don't pollute prefs for next tests. |
|
16 Services.prefs.clearUserPref("network.cookies.cookieBehavior"); |
|
17 Services.prefs.clearUserPref("network.cookie.lifetimePolicy"); |
|
18 Services.prefs.clearUserPref("browser.rights.override"); |
|
19 Services.prefs.clearUserPref("browser.rights." + gRightsVersion + ".shown"); |
|
20 }); |
|
21 |
|
22 let gTests = [ |
|
23 |
|
24 { |
|
25 desc: "Check that clearing cookies does not clear storage", |
|
26 setup: function () |
|
27 { |
|
28 Cc["@mozilla.org/observer-service;1"] |
|
29 .getService(Ci.nsIObserverService) |
|
30 .notifyObservers(null, "cookie-changed", "cleared"); |
|
31 }, |
|
32 run: function (aSnippetsMap) |
|
33 { |
|
34 isnot(aSnippetsMap.get("snippets-last-update"), null, |
|
35 "snippets-last-update should have a value"); |
|
36 } |
|
37 }, |
|
38 |
|
39 { |
|
40 desc: "Check default snippets are shown", |
|
41 setup: function () { }, |
|
42 run: function () |
|
43 { |
|
44 let doc = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
45 let snippetsElt = doc.getElementById("snippets"); |
|
46 ok(snippetsElt, "Found snippets element") |
|
47 is(snippetsElt.getElementsByTagName("span").length, 1, |
|
48 "A default snippet is present."); |
|
49 } |
|
50 }, |
|
51 |
|
52 { |
|
53 desc: "Check default snippets are shown if snippets are invalid xml", |
|
54 setup: function (aSnippetsMap) |
|
55 { |
|
56 // This must be some incorrect xhtml code. |
|
57 aSnippetsMap.set("snippets", "<p><b></p></b>"); |
|
58 }, |
|
59 run: function (aSnippetsMap) |
|
60 { |
|
61 let doc = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
62 |
|
63 let snippetsElt = doc.getElementById("snippets"); |
|
64 ok(snippetsElt, "Found snippets element"); |
|
65 is(snippetsElt.getElementsByTagName("span").length, 1, |
|
66 "A default snippet is present."); |
|
67 |
|
68 aSnippetsMap.delete("snippets"); |
|
69 } |
|
70 }, |
|
71 |
|
72 { |
|
73 desc: "Check that search engine logo has alt text", |
|
74 setup: function () { }, |
|
75 run: function () |
|
76 { |
|
77 let doc = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
78 |
|
79 let searchEngineLogoElt = doc.getElementById("searchEngineLogo"); |
|
80 ok(searchEngineLogoElt, "Found search engine logo"); |
|
81 |
|
82 let altText = searchEngineLogoElt.alt; |
|
83 ok(typeof altText == "string" && altText.length > 0, |
|
84 "Search engine logo's alt text is a nonempty string"); |
|
85 |
|
86 isnot(altText, "undefined", |
|
87 "Search engine logo's alt text shouldn't be the string 'undefined'"); |
|
88 } |
|
89 }, |
|
90 |
|
91 // Disabled on Linux for intermittent issues with FHR, see Bug 945667. |
|
92 { |
|
93 desc: "Check that performing a search fires a search event and records to " + |
|
94 "Firefox Health Report.", |
|
95 setup: function () { }, |
|
96 run: function () { |
|
97 // Skip this test on Linux. |
|
98 if (navigator.platform.indexOf("Linux") == 0) { return; } |
|
99 |
|
100 try { |
|
101 let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); |
|
102 cm.getCategoryEntry("healthreport-js-provider-default", "SearchesProvider"); |
|
103 } catch (ex) { |
|
104 // Health Report disabled, or no SearchesProvider. |
|
105 return Promise.resolve(); |
|
106 } |
|
107 |
|
108 let numSearchesBefore = 0; |
|
109 let searchEventDeferred = Promise.defer(); |
|
110 let doc = gBrowser.contentDocument; |
|
111 let engineName = doc.documentElement.getAttribute("searchEngineName"); |
|
112 |
|
113 doc.addEventListener("AboutHomeSearchEvent", function onSearch(e) { |
|
114 let data = JSON.parse(e.detail); |
|
115 is(data.engineName, engineName, "Detail is search engine name"); |
|
116 |
|
117 // We use executeSoon() to ensure that this code runs after the |
|
118 // count has been updated in browser.js, since it uses the same |
|
119 // event. |
|
120 executeSoon(function () { |
|
121 getNumberOfSearches(engineName).then(num => { |
|
122 is(num, numSearchesBefore + 1, "One more search recorded."); |
|
123 searchEventDeferred.resolve(); |
|
124 }); |
|
125 }); |
|
126 }, true, true); |
|
127 |
|
128 // Get the current number of recorded searches. |
|
129 let searchStr = "a search"; |
|
130 getNumberOfSearches(engineName).then(num => { |
|
131 numSearchesBefore = num; |
|
132 |
|
133 info("Perform a search."); |
|
134 doc.getElementById("searchText").value = searchStr; |
|
135 doc.getElementById("searchSubmit").click(); |
|
136 }); |
|
137 |
|
138 let expectedURL = Services.search.currentEngine. |
|
139 getSubmission(searchStr, null, "homepage"). |
|
140 uri.spec; |
|
141 let loadPromise = waitForDocLoadAndStopIt(expectedURL); |
|
142 |
|
143 return Promise.all([searchEventDeferred.promise, loadPromise]); |
|
144 } |
|
145 }, |
|
146 |
|
147 { |
|
148 desc: "Check snippets map is cleared if cached version is old", |
|
149 setup: function (aSnippetsMap) |
|
150 { |
|
151 aSnippetsMap.set("snippets", "test"); |
|
152 aSnippetsMap.set("snippets-cached-version", 0); |
|
153 }, |
|
154 run: function (aSnippetsMap) |
|
155 { |
|
156 ok(!aSnippetsMap.has("snippets"), "snippets have been properly cleared"); |
|
157 ok(!aSnippetsMap.has("snippets-cached-version"), |
|
158 "cached-version has been properly cleared"); |
|
159 } |
|
160 }, |
|
161 |
|
162 { |
|
163 desc: "Check cached snippets are shown if cached version is current", |
|
164 setup: function (aSnippetsMap) |
|
165 { |
|
166 aSnippetsMap.set("snippets", "test"); |
|
167 }, |
|
168 run: function (aSnippetsMap) |
|
169 { |
|
170 let doc = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
171 |
|
172 let snippetsElt = doc.getElementById("snippets"); |
|
173 ok(snippetsElt, "Found snippets element"); |
|
174 is(snippetsElt.innerHTML, "test", "Cached snippet is present."); |
|
175 |
|
176 is(aSnippetsMap.get("snippets"), "test", "snippets still cached"); |
|
177 is(aSnippetsMap.get("snippets-cached-version"), |
|
178 AboutHomeUtils.snippetsVersion, |
|
179 "cached-version is correct"); |
|
180 ok(aSnippetsMap.has("snippets-last-update"), "last-update still exists"); |
|
181 } |
|
182 }, |
|
183 |
|
184 { |
|
185 desc: "Check if the 'Know Your Rights default snippet is shown when 'browser.rights.override' pref is set", |
|
186 beforeRun: function () |
|
187 { |
|
188 Services.prefs.setBoolPref("browser.rights.override", false); |
|
189 }, |
|
190 setup: function () { }, |
|
191 run: function (aSnippetsMap) |
|
192 { |
|
193 let doc = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
194 let showRights = AboutHomeUtils.showKnowYourRights; |
|
195 |
|
196 ok(showRights, "AboutHomeUtils.showKnowYourRights should be TRUE"); |
|
197 |
|
198 let snippetsElt = doc.getElementById("snippets"); |
|
199 ok(snippetsElt, "Found snippets element"); |
|
200 is(snippetsElt.getElementsByTagName("a")[0].href, "about:rights", "Snippet link is present."); |
|
201 |
|
202 Services.prefs.clearUserPref("browser.rights.override"); |
|
203 } |
|
204 }, |
|
205 |
|
206 { |
|
207 desc: "Check if the 'Know Your Rights default snippet is NOT shown when 'browser.rights.override' pref is NOT set", |
|
208 beforeRun: function () |
|
209 { |
|
210 Services.prefs.setBoolPref("browser.rights.override", true); |
|
211 }, |
|
212 setup: function () { }, |
|
213 run: function (aSnippetsMap) |
|
214 { |
|
215 let doc = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
216 let rightsData = AboutHomeUtils.knowYourRightsData; |
|
217 |
|
218 ok(!rightsData, "AboutHomeUtils.knowYourRightsData should be FALSE"); |
|
219 |
|
220 let snippetsElt = doc.getElementById("snippets"); |
|
221 ok(snippetsElt, "Found snippets element"); |
|
222 ok(snippetsElt.getElementsByTagName("a")[0].href != "about:rights", "Snippet link should not point to about:rights."); |
|
223 |
|
224 Services.prefs.clearUserPref("browser.rights.override"); |
|
225 } |
|
226 }, |
|
227 |
|
228 { |
|
229 desc: "Check that the search UI/ action is updated when the search engine is changed", |
|
230 setup: function() {}, |
|
231 run: function() |
|
232 { |
|
233 let currEngine = Services.search.currentEngine; |
|
234 let unusedEngines = [].concat(Services.search.getVisibleEngines()).filter(x => x != currEngine); |
|
235 let searchbar = document.getElementById("searchbar"); |
|
236 |
|
237 function checkSearchUI(engine) { |
|
238 let doc = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
239 let searchText = doc.getElementById("searchText"); |
|
240 let logoElt = doc.getElementById("searchEngineLogo"); |
|
241 let engineName = doc.documentElement.getAttribute("searchEngineName"); |
|
242 |
|
243 is(engineName, engine.name, "Engine name should've been updated"); |
|
244 |
|
245 if (!logoElt.parentNode.hidden) { |
|
246 is(logoElt.alt, engineName, "Alt text of logo image should match search engine name") |
|
247 } else { |
|
248 is(searchText.placeholder, engineName, "Placeholder text should match search engine name"); |
|
249 } |
|
250 } |
|
251 // Do a sanity check that all attributes are correctly set to begin with |
|
252 checkSearchUI(currEngine); |
|
253 |
|
254 let deferred = Promise.defer(); |
|
255 promiseBrowserAttributes(gBrowser.selectedTab).then(function() { |
|
256 // Test if the update propagated |
|
257 checkSearchUI(unusedEngines[0]); |
|
258 searchbar.currentEngine = currEngine; |
|
259 deferred.resolve(); |
|
260 }); |
|
261 |
|
262 // The following cleanup function will set currentEngine back to the previous |
|
263 // engine if we fail to do so above. |
|
264 registerCleanupFunction(function() { |
|
265 searchbar.currentEngine = currEngine; |
|
266 }); |
|
267 // Set the current search engine to an unused one |
|
268 searchbar.currentEngine = unusedEngines[0]; |
|
269 searchbar.select(); |
|
270 return deferred.promise; |
|
271 } |
|
272 }, |
|
273 |
|
274 { |
|
275 desc: "Check POST search engine support", |
|
276 setup: function() {}, |
|
277 run: function() |
|
278 { |
|
279 let deferred = Promise.defer(); |
|
280 let currEngine = Services.search.defaultEngine; |
|
281 let searchObserver = function search_observer(aSubject, aTopic, aData) { |
|
282 let engine = aSubject.QueryInterface(Ci.nsISearchEngine); |
|
283 info("Observer: " + aData + " for " + engine.name); |
|
284 |
|
285 if (aData != "engine-added") |
|
286 return; |
|
287 |
|
288 if (engine.name != "POST Search") |
|
289 return; |
|
290 |
|
291 // Ready to execute the tests! |
|
292 let needle = "Search for something awesome."; |
|
293 let document = gBrowser.selectedTab.linkedBrowser.contentDocument; |
|
294 let searchText = document.getElementById("searchText"); |
|
295 |
|
296 // We're about to change the search engine. Once the change has |
|
297 // propagated to the about:home content, we want to perform a search. |
|
298 let mutationObserver = new MutationObserver(function (mutations) { |
|
299 for (let mutation of mutations) { |
|
300 if (mutation.attributeName == "searchEngineName") { |
|
301 searchText.value = needle; |
|
302 searchText.focus(); |
|
303 EventUtils.synthesizeKey("VK_RETURN", {}); |
|
304 } |
|
305 } |
|
306 }); |
|
307 mutationObserver.observe(document.documentElement, { attributes: true }); |
|
308 |
|
309 // Change the search engine, triggering the observer above. |
|
310 Services.search.defaultEngine = engine; |
|
311 |
|
312 registerCleanupFunction(function() { |
|
313 mutationObserver.disconnect(); |
|
314 Services.search.removeEngine(engine); |
|
315 Services.search.defaultEngine = currEngine; |
|
316 }); |
|
317 |
|
318 |
|
319 // When the search results load, check them for correctness. |
|
320 waitForLoad(function() { |
|
321 let loadedText = gBrowser.contentDocument.body.textContent; |
|
322 ok(loadedText, "search page loaded"); |
|
323 is(loadedText, "searchterms=" + escape(needle.replace(/\s/g, "+")), |
|
324 "Search text should arrive correctly"); |
|
325 deferred.resolve(); |
|
326 }); |
|
327 }; |
|
328 Services.obs.addObserver(searchObserver, "browser-search-engine-modified", false); |
|
329 registerCleanupFunction(function () { |
|
330 Services.obs.removeObserver(searchObserver, "browser-search-engine-modified"); |
|
331 }); |
|
332 Services.search.addEngine("http://test:80/browser/browser/base/content/test/general/POSTSearchEngine.xml", |
|
333 Ci.nsISearchEngine.DATA_XML, null, false); |
|
334 return deferred.promise; |
|
335 } |
|
336 }, |
|
337 |
|
338 { |
|
339 desc: "Make sure that a page can't imitate about:home", |
|
340 setup: function () { }, |
|
341 run: function (aSnippetsMap) |
|
342 { |
|
343 let deferred = Promise.defer(); |
|
344 |
|
345 let browser = gBrowser.selectedTab.linkedBrowser; |
|
346 waitForLoad(() => { |
|
347 let button = browser.contentDocument.getElementById("settings"); |
|
348 ok(button, "Found settings button in test page"); |
|
349 button.click(); |
|
350 |
|
351 // It may take a few turns of the event loop before the window |
|
352 // is displayed, so we wait. |
|
353 function check(n) { |
|
354 let win = Services.wm.getMostRecentWindow("Browser:Preferences"); |
|
355 ok(!win, "Preferences window not showing"); |
|
356 if (win) { |
|
357 win.close(); |
|
358 } |
|
359 |
|
360 if (n > 0) { |
|
361 executeSoon(() => check(n-1)); |
|
362 } else { |
|
363 deferred.resolve(); |
|
364 } |
|
365 } |
|
366 |
|
367 check(5); |
|
368 }); |
|
369 |
|
370 browser.loadURI("https://example.com/browser/browser/base/content/test/general/test_bug959531.html"); |
|
371 return deferred.promise; |
|
372 } |
|
373 }, |
|
374 |
|
375 ]; |
|
376 |
|
377 function test() |
|
378 { |
|
379 waitForExplicitFinish(); |
|
380 requestLongerTimeout(2); |
|
381 ignoreAllUncaughtExceptions(); |
|
382 |
|
383 Task.spawn(function () { |
|
384 for (let test of gTests) { |
|
385 info(test.desc); |
|
386 |
|
387 if (test.beforeRun) |
|
388 yield test.beforeRun(); |
|
389 |
|
390 // Create a tab to run the test. |
|
391 let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank"); |
|
392 |
|
393 // Add an event handler to modify the snippets map once it's ready. |
|
394 let snippetsPromise = promiseSetupSnippetsMap(tab, test.setup); |
|
395 |
|
396 // Start loading about:home and wait for it to complete. |
|
397 yield promiseTabLoadEvent(tab, "about:home", "AboutHomeLoadSnippetsSucceeded"); |
|
398 |
|
399 // This promise should already be resolved since the page is done, |
|
400 // but we still want to get the snippets map out of it. |
|
401 let snippetsMap = yield snippetsPromise; |
|
402 |
|
403 info("Running test"); |
|
404 yield test.run(snippetsMap); |
|
405 info("Cleanup"); |
|
406 gBrowser.removeCurrentTab(); |
|
407 } |
|
408 }).then(finish, ex => { |
|
409 ok(false, "Unexpected Exception: " + ex); |
|
410 finish(); |
|
411 }); |
|
412 } |
|
413 |
|
414 /** |
|
415 * Starts a load in an existing tab and waits for it to finish (via some event). |
|
416 * |
|
417 * @param aTab |
|
418 * The tab to load into. |
|
419 * @param aUrl |
|
420 * The url to load. |
|
421 * @param aEvent |
|
422 * The load event type to wait for. Defaults to "load". |
|
423 * @return {Promise} resolved when the event is handled. |
|
424 */ |
|
425 function promiseTabLoadEvent(aTab, aURL, aEventType="load") |
|
426 { |
|
427 let deferred = Promise.defer(); |
|
428 info("Wait tab event: " + aEventType); |
|
429 aTab.linkedBrowser.addEventListener(aEventType, function load(event) { |
|
430 if (event.originalTarget != aTab.linkedBrowser.contentDocument || |
|
431 event.target.location.href == "about:blank") { |
|
432 info("skipping spurious load event"); |
|
433 return; |
|
434 } |
|
435 aTab.linkedBrowser.removeEventListener(aEventType, load, true); |
|
436 info("Tab event received: " + aEventType); |
|
437 deferred.resolve(); |
|
438 }, true, true); |
|
439 aTab.linkedBrowser.loadURI(aURL); |
|
440 return deferred.promise; |
|
441 } |
|
442 |
|
443 /** |
|
444 * Cleans up snippets and ensures that by default we don't try to check for |
|
445 * remote snippets since that may cause network bustage or slowness. |
|
446 * |
|
447 * @param aTab |
|
448 * The tab containing about:home. |
|
449 * @param aSetupFn |
|
450 * The setup function to be run. |
|
451 * @return {Promise} resolved when the snippets are ready. Gets the snippets map. |
|
452 */ |
|
453 function promiseSetupSnippetsMap(aTab, aSetupFn) |
|
454 { |
|
455 let deferred = Promise.defer(); |
|
456 info("Waiting for snippets map"); |
|
457 aTab.linkedBrowser.addEventListener("AboutHomeLoadSnippets", function load(event) { |
|
458 aTab.linkedBrowser.removeEventListener("AboutHomeLoadSnippets", load, true); |
|
459 |
|
460 let cw = aTab.linkedBrowser.contentWindow.wrappedJSObject; |
|
461 // The snippets should already be ready by this point. Here we're |
|
462 // just obtaining a reference to the snippets map. |
|
463 cw.ensureSnippetsMapThen(function (aSnippetsMap) { |
|
464 info("Got snippets map: " + |
|
465 "{ last-update: " + aSnippetsMap.get("snippets-last-update") + |
|
466 ", cached-version: " + aSnippetsMap.get("snippets-cached-version") + |
|
467 " }"); |
|
468 // Don't try to update. |
|
469 aSnippetsMap.set("snippets-last-update", Date.now()); |
|
470 aSnippetsMap.set("snippets-cached-version", AboutHomeUtils.snippetsVersion); |
|
471 // Clear snippets. |
|
472 aSnippetsMap.delete("snippets"); |
|
473 aSetupFn(aSnippetsMap); |
|
474 deferred.resolve(aSnippetsMap); |
|
475 }); |
|
476 }, true, true); |
|
477 return deferred.promise; |
|
478 } |
|
479 |
|
480 /** |
|
481 * Waits for the attributes being set by browser.js. |
|
482 * |
|
483 * @param aTab |
|
484 * The tab containing about:home. |
|
485 * @return {Promise} resolved when the attributes are ready. |
|
486 */ |
|
487 function promiseBrowserAttributes(aTab) |
|
488 { |
|
489 let deferred = Promise.defer(); |
|
490 |
|
491 let docElt = aTab.linkedBrowser.contentDocument.documentElement; |
|
492 let observer = new MutationObserver(function (mutations) { |
|
493 for (let mutation of mutations) { |
|
494 info("Got attribute mutation: " + mutation.attributeName + |
|
495 " from " + mutation.oldValue); |
|
496 // Now we just have to wait for the last attribute. |
|
497 if (mutation.attributeName == "searchEngineName") { |
|
498 info("Remove attributes observer"); |
|
499 observer.disconnect(); |
|
500 // Must be sure to continue after the page mutation observer. |
|
501 executeSoon(function() deferred.resolve()); |
|
502 break; |
|
503 } |
|
504 } |
|
505 }); |
|
506 info("Add attributes observer"); |
|
507 observer.observe(docElt, { attributes: true }); |
|
508 |
|
509 return deferred.promise; |
|
510 } |
|
511 |
|
512 /** |
|
513 * Retrieves the number of about:home searches recorded for the current day. |
|
514 * |
|
515 * @param aEngineName |
|
516 * name of the setup search engine. |
|
517 * |
|
518 * @return {Promise} Returns a promise resolving to the number of searches. |
|
519 */ |
|
520 function getNumberOfSearches(aEngineName) { |
|
521 let reporter = Components.classes["@mozilla.org/datareporting/service;1"] |
|
522 .getService() |
|
523 .wrappedJSObject |
|
524 .healthReporter; |
|
525 ok(reporter, "Health Reporter instance available."); |
|
526 |
|
527 return reporter.onInit().then(function onInit() { |
|
528 let provider = reporter.getProvider("org.mozilla.searches"); |
|
529 ok(provider, "Searches provider is available."); |
|
530 |
|
531 let m = provider.getMeasurement("counts", 3); |
|
532 return m.getValues().then(data => { |
|
533 let now = new Date(); |
|
534 let yday = new Date(now); |
|
535 yday.setDate(yday.getDate() - 1); |
|
536 |
|
537 // Add the number of searches recorded yesterday to the number of searches |
|
538 // recorded today. This makes the test not fail intermittently when it is |
|
539 // run at midnight and we accidentally compare the number of searches from |
|
540 // different days. Tests are always run with an empty profile so there |
|
541 // are no searches from yesterday, normally. Should the test happen to run |
|
542 // past midnight we make sure to count them in as well. |
|
543 return getNumberOfSearchesByDate(aEngineName, data, now) + |
|
544 getNumberOfSearchesByDate(aEngineName, data, yday); |
|
545 }); |
|
546 }); |
|
547 } |
|
548 |
|
549 function getNumberOfSearchesByDate(aEngineName, aData, aDate) { |
|
550 if (aData.days.hasDay(aDate)) { |
|
551 let id = Services.search.getEngineByName(aEngineName).identifier; |
|
552 |
|
553 let day = aData.days.getDay(aDate); |
|
554 let field = id + ".abouthome"; |
|
555 |
|
556 if (day.has(field)) { |
|
557 return day.get(field) || 0; |
|
558 } |
|
559 } |
|
560 |
|
561 return 0; // No records found. |
|
562 } |
|
563 |
|
564 function waitForLoad(cb) { |
|
565 let browser = gBrowser.selectedBrowser; |
|
566 browser.addEventListener("load", function listener() { |
|
567 if (browser.currentURI.spec == "about:blank") |
|
568 return; |
|
569 info("Page loaded: " + browser.currentURI.spec); |
|
570 browser.removeEventListener("load", listener, true); |
|
571 |
|
572 cb(); |
|
573 }, true); |
|
574 } |