diff options
Diffstat (limited to 'testing/web-platform/tests/scroll-to-text-fragment')
36 files changed, 2205 insertions, 0 deletions
diff --git a/testing/web-platform/tests/scroll-to-text-fragment/META.yml b/testing/web-platform/tests/scroll-to-text-fragment/META.yml new file mode 100644 index 0000000000..118ab130db --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/META.yml @@ -0,0 +1,4 @@ +spec: https://wicg.github.io/scroll-to-text-fragment/ +suggested_reviewers: + - nburris + - bokan diff --git a/testing/web-platform/tests/scroll-to-text-fragment/client-redirect.html b/testing/web-platform/tests/scroll-to-text-fragment/client-redirect.html new file mode 100644 index 0000000000..73858969e0 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/client-redirect.html @@ -0,0 +1,12 @@ +<!doctype html> +<script> + const query = window.location.search.substr(1); + const type = query.split('&')[0]; + const url = decodeURIComponent(query.split('&')[1]); + + if (type == 'meta') { + document.write(`<meta http-equiv="Refresh" content="0; URL=${url}">`); + } else if (type == 'location') { + window.location = url; + } +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive-target.html b/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive-target.html new file mode 100644 index 0000000000..dc02ebd874 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive-target.html @@ -0,0 +1,78 @@ +<!doctype html> +<title>Tests find-a-range-from-a-text-directive algorithm</title> +<script src="stash.js"></script> +<script> +window.didScroll = false; + +function checkScroll() { + let results = { + didScroll: window.scrollY != 0 + }; + + let key = (new URL(document.location)).searchParams.get("key"); + stashResultsThenClose(key, results); +} + +// Ensure two animation frames on load to test the fallback to element anchor, +// which gets queued for the next frame if the text fragment is not found. +window.onload = function() { + window.requestAnimationFrame(function() { + window.requestAnimationFrame(checkScroll); + }); +} +</script> +<style> + .spacer { + width: 50vw; + height: 200vh; + } +</style> +<body> + Won't scroll if matched: + match suffix + match suffix3 + <div class="spacer"></div> + <p> + The quick brown fox jumped over the lazy dog. + a a b b b c + </p> + <p> + foo foo foo bar bar bar + </p> + <p> + match suffix2 + prefix match suffix3 matchEnd suffix4 matchEnd suffix5 + </p> + <p> + Lorem + + <br> <i> </i> + <div>   </div> + <!-- This <p> puts lots of non textual stuff between words --> + <div style="display: none">This isn't rendered</div> + <div style="visibility: hidden">This also isn't visible</div> + <iframe srcdoc="Inner Iframe"></iframe> + <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="> + +   Ipsum + + Whitespace + + <br> <i> </i> + <div>   </div> + <!-- This <p> puts lots of non textual stuff between words --> + <div style="display: none">This isn't rendered</div> + <div style="visibility: hidden">This also isn't visible</div> + <iframe srcdoc="Inner Iframe"></iframe> + <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="> +   + + Dipsum + + + + </p> + <p> + This text appears at the end of the document + </p +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive.html b/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive.html new file mode 100644 index 0000000000..229f082747 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive.html @@ -0,0 +1,305 @@ +<!doctype html> +<title>Tests find-a-range-from-a-text-directive algorithm</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/scroll-to-text-fragment/#find-a-range-from-a-text-directive"> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/common/utils.js"></script> +<script src="stash.js"></script> +<!-- + This test suite performs scroll to text navigations to + find-range-from-text-directive-target.html and then checks the results, which are + communicated back from the target page via the WPT Stash server (see stash.py). + This structure is necessary because scroll to text security restrictions + specifically restrict the navigator from being able to observe the result of + the navigation, e.g. the target page cannot have a window opener. +--> +<script> +// This test suite specifically exercises the "find a range from a text +// directive" algorithm in the spec. + +let test_cases = [ + { + fragment: '#:~:text=jumped', + expect_to_scroll: true, + description: 'Basic smoke test - full word match' + }, + { + // Step 2.2.1 + fragment: '#:~:text=u-,mped', + expect_to_scroll: false, + description: 'Prefix must start on a word boundary' + }, + { + // Step 2.2.1 + fragment: '#:~:text=ju-,mped', + expect_to_scroll: true, + description: 'Prefix need not end on a word boundary' + }, + { + // Step 2.2.2 + fragment: '#:~:text=null-,The%20quick', + expect_to_scroll: false, + description: 'Prefix doesn\'t exist' + }, + { + // Step 2.2.3 + fragment: '#:~:text=foo%20foo-,bar', + expect_to_scroll: true, + description: 'Multiple overlapping prefixes' + }, + { + // Step 2.2.3 + fragment: '#:~:text=a%20a-,b', + expect_to_scroll: true, + description: 'Multiple overlapping one letter prefixes' + }, + { + // Step 2.2.4 + fragment: '#:~:text=quick%20brown-,brown%20fox', + expect_to_scroll: false, + description: 'Prefix overlaps match text' + }, + { + // Step 2.2.4 + fragment: '#:~:text=quick%20brown-,fox', + expect_to_scroll: true, + description: 'Match text after prefix' + }, + { + // Step 2.2.5 + fragment: '#:~:text=Lorem-,Ipsum', + expect_to_scroll: true, + description: 'Search invisible content between prefix and match' + }, + { + // Step 2.2.6 + fragment: '#:~:text=end%20of%20the%20document-,test', + expect_to_scroll: false, + description: 'Prefix appears at end of document' + }, + { + // Step 2.2.8 + fragment: '#:~:text=fox-,jum,over', + expect_to_scroll: false, + description: '|end| forces |start| to end on word boundary' + }, + { + // Step 2.2.8 + fragment: '#:~:text=fox-,jum', + expect_to_scroll: false, + description: 'no |end| or suffix forces |start| to end on word boundary' + }, + { + // Step 2.2.8 + fragment: '#:~:text=fox-,jum,-ped', + expect_to_scroll: true, + description: 'suffix means |start| need not end on word boundary' + }, + { + // Step 2.2.9 + fragment: '#:~:text=jum-,ped', + expect_to_scroll: true, + description: '|start| doesn\'t need to start on word boundary' + }, + { + // Step 2.2.10 + fragment: '#:~:text=jumped-,null', + expect_to_scroll: false, + description: 'prefix with non-existent exact match' + }, + { + // Step 2.2.10 + fragment: '#:~:text=jumped-,null,lazy', + expect_to_scroll: false, + description: 'prefix with non-existent range match' + }, + { + // Step 2.2.11 + fragment: '#:~:text=brown-,jumped', + expect_to_scroll: false, + description: 'match doesn\'t immediately follow prefix' + }, + { + // Step 2.2.11 + fragment: '#:~:text=foo-,bar', + expect_to_scroll: true, + description: 'match doesn\'t immediately follow first prefix instance' + }, + { + // Step 2.3.1 + fragment: '#:~:text=jum,over', + expect_to_scroll: false, + description: 'no-prefix; |end| forces |start| to end on word boundary' + }, + { + // Step 2.3.1 + fragment: '#:~:text=jum', + expect_to_scroll: false, + description: 'no-prefix; no |end| or suffix forces |start| to end on word boundary' + }, + { + // Step 2.3.1 + fragment: '#:~:text=jum,-ped', + expect_to_scroll: true, + description: 'no-prefix; suffix means |start| need not end on word boundary' + }, + { + // Step 2.3.2 + fragment: '#:~:text=umped', + expect_to_scroll: false, + description: '|start| must start on a word boundary' + }, + { + // Step 2.3.3 + fragment: '#:~:text=null', + expect_to_scroll: false, + description: 'non-existent exact match' + }, + { + // Step 2.3.3 + fragment: '#:~:text=null,lazy', + expect_to_scroll: false, + description: 'non-existent range match' + }, + { + // Step 2.3.4 + fragment: '#:~:text=b%20b,-c', + expect_to_scroll: true, + description: 'overlapping exact matches with suffix' + }, + { + // Step 2.3.4 + fragment: '#:~:text=foo%20foo,-bar', + expect_to_scroll: true, + description: 'overlapping one letter exact matches with suffix' + }, + { + // Step 2.5.1 + fragment: '#:~:text=brown,fox', + expect_to_scroll: true, + description: 'matching range search' + }, + { + // Step 2.4 + fragment: '#:~:text=brown,quick', + expect_to_scroll: false, + description: 'inverted range search' + }, + { + // Step 2.5.1.1 + fragment: '#:~:text=quick,bro', + expect_to_scroll: false, + description: 'no suffix forces |end| to be end bounded' + }, + { + // Step 2.5.1.1 + fragment: '#:~:text=quick,bro,-wn', + expect_to_scroll: true, + description: 'suffix means |end| need not be end bounded' + }, + { + // Step 2.5.1.2 + fragment: '#:~:text=quick,ro,-wn', + expect_to_scroll: false, + description: '|end| must be start bounded' + }, + { + // Step 2.5.1.2 + fragment: '#:~:text=bro,wn', + expect_to_scroll: false, + description: '|end| must be start bounded even if full range is word bounded' + }, + { + // Step 2.5.1.3 + fragment: '#:~:text=quick,null', + expect_to_scroll: false, + description: 'non-existent |end|' + }, + { + // Step 2.5.1.4 + fragment: '#:~:text=quick,jumped,-fox', + expect_to_scroll: false, + description: 'Range with preceeding suffix' + }, + { + // Step 2.5.3 + fragment: '#:~:text=The-,quick,brown', + expect_to_scroll: true, + description: 'Match with no suffix' + }, + { + // Step 2.5.4 + fragment: '#:~:text=The-,quick,fox,-brown', + expect_to_scroll: false, + description: 'Suffix comes before |end|' + }, + { + // Step 2.5.5 + fragment: '#:~:text=Lorem-,Ipsum,Whitespace,-Dipsum', + expect_to_scroll: true, + description: 'Search invisible content between |end| and suffix' + }, + { + // Step 2.5.6 + fragment: '#:~:text=quick,-bro', + expect_to_scroll: false, + description: 'Suffix must be end bounded' + }, + { + // Step 2.5.6 + fragment: '#:~:text=qu,-ick', + expect_to_scroll: true, + description: 'Suffix need not be start bounded' + }, + { + // Step 2.5.7 + fragment: '#:~:text=quick,-null', + expect_to_scroll: false, + description: 'Non-existent suffix' + }, + { + // Step 2.5.8 + fragment: '#:~:text=quick,-fox', + expect_to_scroll: false, + description: 'Content appears between match and suffix' + }, + { + // Step 2.5.9 + fragment: '#:~:text=match,-suffix2', + expect_to_scroll: true, + description: 'Non-matching suffix in first potential match' + }, + { + // Step 2.5.9 + fragment: '#:~:text=prefix-,match,-suffix3', + expect_to_scroll: true, + description: 'Non-matching suffix search continues to prefix match' + }, + { + // Step 2.5.10 + fragment: '#:~:text=prefix-,match,matchEnd,-suffix5', + expect_to_scroll: true, + description: 'Range end matches correct suffix' + } +]; + +for (const test_case of test_cases) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(`find-range-from-text-directive-target.html?key=${key}${test_case.fragment}`, '_blank', 'noopener'); + }); + + fetchResults(key, resolve, reject); + }).then(data => { + const expectation_string = test_case.expect_to_scroll ? 'to scroll' : 'not to scroll'; + assert_equals(data.didScroll, test_case.expect_to_scroll, + `Expected ${expectation_string}`); + }), `${test_case.description}.`); +} +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top-target.html b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top-target.html new file mode 100644 index 0000000000..72feec8a40 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top-target.html @@ -0,0 +1,15 @@ +<!doctype html> +<title>Navigating to a text fragment anchor</title> +<script src="stash.js"></script> +<script src="force-load-at-top.js"></script> +<style> + p#target { + margin: 2000px 0px 2000px 0px; + } +</style> +<!-- This page is loaded with the force-load-at-top Document-Policy header --> +<body> + <p>Top of page</p> + <p id="target">target</p> + <p id="history">history</p> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top-target.html.headers b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top-target.html.headers new file mode 100644 index 0000000000..33dcdbb01f --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top-target.html.headers @@ -0,0 +1 @@ +Document-Policy: force-load-at-top diff --git a/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top.html b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top.html new file mode 100644 index 0000000000..fe3913dfac --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top.html @@ -0,0 +1,61 @@ +<!doctype html> +<title>ForceLoadAtTop blocks scroll on load</title> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/common/utils.js"></script> +<script src="stash.js"></script> + +<!--See comment in scroll-to-text-fragment.html for why this test has the +structure it has --> +<script> + +let test_cases = [ + { + fragment: '#:~:text=target', + type: 'text fragment' + }, + { + fragment: '#target:~:text=target', + type: 'text fragment with element fallback' + }, + { + fragment: '#target', + type: 'element fragment' + }, + { + fragment: '#history', + type: 'history scroll restoration' + }, +]; + +let document_policy_value = [ + 'force-load-at-top', + 'no-force-load-at-top' +]; + +for (const value of document_policy_value) { + // If no-force-load-at-top is specified we expect to allow scrolling, + // otherwise scroll on load should be blocked. + const scroll_expected = value == 'no-force-load-at-top'; + const block_verb = scroll_expected ? "must not" : "must"; + + for (const test_case of test_cases) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(`${value}-target.html?key=${key}${test_case.fragment}`, '_blank', 'noopener'); + }); + + fetchResults(key, resolve, reject); + }).then(data => { + assert_equals(data.scrolled, scroll_expected); + }), `${value} ${block_verb} block scroll on load from ${test_case.type}.`); + } +} +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top.js b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top.js new file mode 100644 index 0000000000..691168859f --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/force-load-at-top.js @@ -0,0 +1,33 @@ +function checkScroll() { + // Ensure two animation frames on load to test the fallback to element + // anchor, which gets queued for the next frame if the text fragment is not + // found. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + let results = { + scrolled: (window.pageYOffset != 0), + }; + + let key = (new URL(document.location)).searchParams.get("key"); + stashResultsThenClose(key, results); + }); + }); +} + +window.addEventListener('pageshow', () => { + if (location.hash == "#history") { + // This is the "history" test - on the first load we'll navigate to a page + // that calls history.back(). When we load a second time (from the back + // navigation), record the scroll state at that point to check how history + // scroll restoration is handled. + if (history.state == null) { + history.pushState("test", "test", ""); + requestAnimationFrame(() => { + location.href = "navigate-back.html"; + }); + return; + } + } + + checkScroll(); +}); diff --git a/testing/web-platform/tests/scroll-to-text-fragment/idlharness.window.js b/testing/web-platform/tests/scroll-to-text-fragment/idlharness.window.js new file mode 100644 index 0000000000..c39216b581 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/idlharness.window.js @@ -0,0 +1,15 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js + +'use strict'; + +idl_test( + ['scroll-to-text-fragment'], + ['dom', 'html'], + idl_array => { + idl_array.add_objects({ + Document: ['document'], + FragmentDirective: ['document.fragmentDirective'], + }); + } +); diff --git a/testing/web-platform/tests/scroll-to-text-fragment/iframe-scroll.sub.html b/testing/web-platform/tests/scroll-to-text-fragment/iframe-scroll.sub.html new file mode 100644 index 0000000000..db959ab68d --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/iframe-scroll.sub.html @@ -0,0 +1,65 @@ +<!doctype html> +<title>Text directive in cross-origin iframe doesn't cause scrolling in main document</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/common/utils.js"></script> +<script src="stash.js"></script> +<style> + iframe { + width: 300px; + height: 300px; + /* Make sure iframe is mostly offscreen but intersects viewport slightly so + * it isn't throttled in any way */ + margin-top: 95vh; + } +</style> + +<iframe></iframe> + +<script> + let iframe_did_scroll = false; + + window.addEventListener('message', (e) => { + if (e.data != 'did_scroll') + throw new Error("Got unexpected message: " + e.data); + if (iframe_did_scroll) + throw new Error("Got multiple messages from single iframe"); + + iframe_did_scroll = true; + }); + + async function wait_for_iframe_scroll(t) { + await t.step_wait(() => iframe_did_scroll == true, "iframe scrolled to text directive", 10000); + iframe_did_scroll = false; + } + + async function rAF() { + return new Promise((resolve) => { + window.requestAnimationFrame(resolve); + }); + } + + onload = () => { + promise_test(async function (t) { + window.scrollTo(0, 0); + + frames[0].location = "http://{{hosts[][www]}}:{{ports[http][0]}}/scroll-to-text-fragment/resources/self-text-directive-iframe.html"; + await wait_for_iframe_scroll(t); + await rAF(); + assert_equals(document.scrollingElement.scrollTop, 0); + }, "CROSS-ORIGIN: Text directive in iframe doesn't bubble to outer frame."); + + promise_test(async function (t) { + window.scrollTo(0, 0); + + frames[0].location = "resources/self-text-directive-iframe.html"; + await wait_for_iframe_scroll(t); + await rAF(); + assert_greater_than(document.scrollingElement.scrollTop, 0); + }, "SAME-ORIGIN: Text directive in iframe bubbles to outer frame."); + } +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/iframe-target.html b/testing/web-platform/tests/scroll-to-text-fragment/iframe-target.html new file mode 100644 index 0000000000..7d57d4920b --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/iframe-target.html @@ -0,0 +1,49 @@ +<!doctype html> +<title>Inner document for use in iframes.sub.html test</title> +<script> + function isInView(element) { + let rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.top <= window.innerHeight + && rect.left >= 0 && rect.left <= window.innerWidth; + } + + function postResult() { + let position = 'unknown'; + if (window.scrollY == 0) + position = 'top'; + else if (isInView(document.getElementById('target'))) + position = 'target'; + else if (isInView(document.getElementById('elementid'))) + position = 'elementid'; + + let results = { + scrollPosition: position, + href: window.location.href, + }; + + window.top.postMessage(results, "*"); + } + + window.addEventListener('message', (e) => { + if (e.data == 'getResult') { + // Use a timeout to get results - in the elementId fallback case, the + // browser may retry the text fragment search a few times before giving + // up and trying the elementid. + setTimeout(postResult, 2000); + } else if (e.data == 'reset') { + window.location.hash = ''; + window.scrollTo(0, 0); + window.top.postMessage('', "*"); + } + }); +</script> +<style> + p { + margin-top: 400vh; + margin-bottom: 400vh; + } +</style> +<body> + <p id="target">Target Text</p> + <div id="elementid">DIV</div> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/iframes.sub.html b/testing/web-platform/tests/scroll-to-text-fragment/iframes.sub.html new file mode 100644 index 0000000000..eb6594c283 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/iframes.sub.html @@ -0,0 +1,113 @@ +<!doctype html> +<title>Navigating to text fragment directives in iframes</title> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + function getResult(iframe) { + return new Promise((resolve) => { + window.addEventListener('message', (e) => { + resolve(e.data); + }, {once: true}); + iframe.contentWindow.postMessage('getResult', '*'); + }); + } + + function reset(iframe) { + return new Promise((resolve) => { + window.addEventListener('message', (e) => { + resolve(); + }, {once: true}); + iframe.contentWindow.postMessage('reset', '*'); + }); + } + + function runTests() { + const attribute_iframe = document.getElementById('srcattrib'); + const sameorigin_iframe = document.getElementById('sameorigin'); + const crossorigin_iframe = document.getElementById('crossorigin'); + + // Check behavior that occurs when a text fragment is specified directly in + // an iframe's src attribute. We expect the text fragment to be blocked. + promise_test(t => new Promise(async resolve => { + // No reset since we're checking the hash specified in the `src` attribute. + const data = await getResult(attribute_iframe); + resolve(data); + }).then( data => { + assert_equals(data.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + assert_equals(data.scrollPosition, 'top', 'Should not scroll to text fragment'); + }), 'Text fragment specified in iframe.src'); + + // Check behavior when we set a text fragment using script from a + // same-origin parent. The text fragment should be allowed because this is + // a same-document navigation initiated by an origin that's same-origin + // with the current document. + promise_test(t => new Promise(async resolve => { + await reset(sameorigin_iframe); + sameorigin_iframe.contentWindow.location = `${sameorigin_iframe.src}#:~:text=Target`; + const data = await getResult(sameorigin_iframe); + resolve(data); + }).then( data => { + assert_equals(data.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + assert_equals(data.scrollPosition, 'target', 'Should scroll to text fragment'); + }), 'Navigate same-origin iframe via window.location'); + + // Check behavior when we set a text fragment using script from a + // cross-origin parent. The text fragment should be blocked because the + // initiating origin is not same-origin with the current document. + promise_test(t => new Promise(async resolve => { + await reset(crossorigin_iframe); + crossorigin_iframe.contentWindow.location = `${crossorigin_iframe.src}#:~:text=Target`; + const data = await getResult(crossorigin_iframe); + resolve(data); + }).then( data => { + assert_equals(data.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + assert_equals(data.scrollPosition, 'top', 'Should not to scroll to text fragment.'); + }), 'Navigate cross-origin iframe via window.location'); + + // Check the element-id fallback behavior when the text is not found. We + // should fallback to a regular element-id hash navigation. + promise_test(t => new Promise(async resolve => { + await reset(sameorigin_iframe); + sameorigin_iframe.contentWindow.location = `${sameorigin_iframe.src}#elementid:~:text=NonExistentText`; + const data = await getResult(sameorigin_iframe); + resolve(data); + }).then( data => { + assert_equals(data.scrollPosition, 'elementid', 'Should scroll to the element-id anchor.'); + }), 'Non-matching text with element-id fallback'); + + // Check the element-id fallback behaviour when used across origins. The + // text fragment should be blocked across origins but the element id hash + // should not. + promise_test(t => new Promise(async resolve => { + await reset(crossorigin_iframe); + crossorigin_iframe.contentWindow.location = `${crossorigin_iframe.src}#elementid:~:text=Target%20Text`; + const data = await getResult(crossorigin_iframe); + resolve(data); + }).then( data => { + assert_equals(data.scrollPosition, 'elementid', 'Should scroll to the element-id anchor.'); + }), 'Cross-origin with element-id fallback'); + } +</script> +<style> + iframe { + width: 100px; + height: 100px; + } +</style> +<body onload="runTests()"> + <div> + Same-Origin with text fragment in src attribute: + <iframe id="srcattrib" src="iframe-target.html#:~:text=Target"></iframe> + </div> + <div> + Same-Origin: + <iframe id="sameorigin" src="iframe-target.html"></iframe> + </div> + <div> + Cross-Origin: + <iframe id="crossorigin" src="http://{{hosts[][www]}}:{{ports[http][0]}}/scroll-to-text-fragment/iframe-target.html"></iframe> + </div> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/navigate-back.html b/testing/web-platform/tests/scroll-to-text-fragment/navigate-back.html new file mode 100644 index 0000000000..4b4117fcc5 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/navigate-back.html @@ -0,0 +1,8 @@ +<!doctype html> +<script> + onload = () => { + requestAnimationFrame(() => { + history.back(); + }); + }; +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/no-force-load-at-top-target.html b/testing/web-platform/tests/scroll-to-text-fragment/no-force-load-at-top-target.html new file mode 100644 index 0000000000..d2641eca72 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/no-force-load-at-top-target.html @@ -0,0 +1,16 @@ +<!doctype html> +<title>Navigating to a text fragment anchor</title> +<script src="stash.js"></script> +<script src="force-load-at-top.js"></script> +<style> + p#target { + margin: 2000px 0px 2000px 0px; + } +</style> +<!-- This page is loaded with the force-load-at-top Document-Policy header set + to false --> +<body> + <p>Top of page</p> + <p id="target">target</p> + <p id="history">history</p> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/no-force-load-at-top-target.html.headers b/testing/web-platform/tests/scroll-to-text-fragment/no-force-load-at-top-target.html.headers new file mode 100644 index 0000000000..e500009f91 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/no-force-load-at-top-target.html.headers @@ -0,0 +1 @@ +Document-Policy: force-load-at-top=?0 diff --git a/testing/web-platform/tests/scroll-to-text-fragment/non-html-documents.html b/testing/web-platform/tests/scroll-to-text-fragment/non-html-documents.html new file mode 100644 index 0000000000..5cec6aee3a --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/non-html-documents.html @@ -0,0 +1,79 @@ +<!doctype html> +<title>Allow text fragments in HTML documents only</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/common/utils.js"></script> +<script src="stash.js"></script> + +<script> +function rAF(win) { + return new Promise((resolve) => { + win.requestAnimationFrame(resolve); + }); +} + +function openPopup(url) { + return new Promise((resolve) => { + test_driver.bless('Open a URL with a text fragment directive', () => { + const popup = window.open(url, '_blank', 'width=300,height=300'); + popup.onload = () => resolve(popup); + }); + }); +} + +const test_cases = [ + { + filename: 'text-html.html', + expected: 'allowed', + }, + { + filename: 'text-css.css', + expected: 'blocked', + }, + { + filename: 'text-javascript.js', + expected: 'blocked', + }, + { + filename: 'application-json.json', + expected: 'blocked', + }, + { + filename: 'text-plain.txt', + expected: 'allowed', + }, + { + filename: 'application-xml.xml', + expected: 'blocked', + }, +]; + +for (let test_case of test_cases) { + const filename = test_case.filename; + const expected = test_case.expected; + const mediaType = filename.split('.')[0].replace('-', '/'); + + promise_test(async function (t) { + const popup = await openPopup(`resources/${filename}#:~:text=target`); + + // The WPT server should provide the correct content-type header from the + // file extension. + assert_equals(popup.document.contentType, mediaType); + + // rAF twice in case there is any asynchronicity in scrolling to the target. + await rAF(popup); + await rAF(popup); + + const did_scroll = popup.scrollY > 0; + const expected_scroll = expected == 'allowed'; + assert_equals(did_scroll, expected_scroll, 'scrolled to fragment'); + + popup.close(); + }, `Text directive ${expected} in ${mediaType}`); +} + +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/percent-encoding.html b/testing/web-platform/tests/scroll-to-text-fragment/percent-encoding.html new file mode 100644 index 0000000000..1f1794bdae --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/percent-encoding.html @@ -0,0 +1,102 @@ +<!DOCTYPE html> +<title>Percent-encoding in a text directive</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="resources/util.js"></script> +<style> + .target { + margin-top: 2000px; + margin-bottom: 2000px; + } +</style> +<script> + +function determineResult() { + if (window.scrollY == 0) + return 'noscroll'; + + for (let target of document.querySelectorAll('.target')) { + if (isInViewport(target)) { + return target.id; + } + } + return 'UNEXPECTED'; +} + +let test_cases = [ + { + fragment: '#:~:text=%25', + expect: 'singlepercent', + description: 'Percent-encoded "%" char.' + }, + { + fragment: '#:~:text=%', + expect: 'noscroll', + description: 'Percent char without hex digits is invalid.' + }, + { + fragment: '#:~:text=%%', + expect: 'noscroll', + description: 'Percent char followed by percent char is invalid.' + }, + { + fragment: '#:~:text=%F', + expect: 'noscroll', + description: 'Single digit percent-encoding is invalid.' + }, + { + fragment: '#:~:text=%25F', + expect: 'percentf', + description: 'Percent-encoding limited to two digits.' + }, + { + fragment: '#:~:text=%25%25F', + expect: 'doublepercentf', + description: 'Percent-encoded "%%F"' + }, + { + fragment: '#:~:text=%E2%9C%85', + expect: 'checkmark', + description: 'Percent-encoding multibyte codepoint (CHECKMARK).' + }, +]; + +for (const test_case of test_cases) { + promise_test(t => new Promise(resolve => { + // Clear the fragment and reset the scroll offset to prepare for the next + // test case. + location = `${location.pathname}#`; + scrollTo(0, 0); + + location = `${location.pathname}${test_case.fragment}`; + requestAnimationFrame( () => requestAnimationFrame(resolve) ); + }).then(() => { + assert_equals(determineResult(), test_case.expect); + }), `Test navigation with fragment: ${test_case.description}.`); +} +</script> + +<p class="target" id="singlepercent"> + % +</p> +<p class="target" id="doublepercent"> + %% +</p> +<p class="target" id="percentf"> + %F +</p> +<p class="target" id="doublepercentf"> + %%f +</p> +<p class="target" id="checkmark"> + <!-- U+2705 WHITE HEAVY CHECK MARK - UTF-8 percent encoding: %E2%9C%85 --> + ✅ +</p> +<p class="target" id="helloworld"> + Hello world +</p> + diff --git a/testing/web-platform/tests/scroll-to-text-fragment/redirects-target.html b/testing/web-platform/tests/scroll-to-text-fragment/redirects-target.html new file mode 100644 index 0000000000..5e44230a95 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/redirects-target.html @@ -0,0 +1,42 @@ +<!doctype html> +<title>Destination of a Redirect</title> +<script src="stash.js"></script> +<script> +function checkScroll() { + // Two rAFs since the exact timing of when we cause scrolling is up to the + // UA. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + let twice = (new URL(document.location)).searchParams.get("twice"); + let key = (new URL(document.location)).searchParams.get("key"); + let results = { + scrolled: (window.pageYOffset != 0), + }; + + if (twice != null) { + // If this param is specified, we'll try to redirect to another + // text-fragment after this one has been invoked. + if (!results.scrolled) { + results.scrolled = null; + stashResultsThenClose(key, results); + throw "Intermediate page failed to scroll to fragment"; + } + + window.location = `redirects-target2.html?key=${key}#:~:text=target`; + } else { + stashResultsThenClose(key, results); + } + }); + }); +} +window.addEventListener('load', checkScroll); +</script> +<style> + p#target { + margin: 2000px 0px 2000px 0px; + } +</style> +<body> + <p>Top of page</p> + <p id="target">target</p> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/redirects-target2.html b/testing/web-platform/tests/scroll-to-text-fragment/redirects-target2.html new file mode 100644 index 0000000000..239c0fe1e6 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/redirects-target2.html @@ -0,0 +1,28 @@ +<!doctype html> +<title>Destination of a Redirect</title> +<script src="stash.js"></script> +<script> +function checkScroll() { + // Two rAFs since the exact timing of when we cause scrolling is up to the + // UA. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + let results = { + scrolled: (window.pageYOffset != 0), + }; + let key = (new URL(document.location)).searchParams.get("key"); + stashResultsThenClose(key, results); + }); + }); +} +window.addEventListener('load', checkScroll); +</script> +<style> + p#target { + margin: 2000px 0px 2000px 0px; + } +</style> +<body> + <p>Top of page</p> + <p id="target">target</p> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/redirects.html b/testing/web-platform/tests/scroll-to-text-fragment/redirects.html new file mode 100644 index 0000000000..71bc1be02e --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/redirects.html @@ -0,0 +1,127 @@ +<!doctype html> +<title>TextFragment invoked on redirects</title> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/common/get-host-info.sub.js"></script> +<script src="/common/utils.js"></script> +<script src="stash.js"></script> + +<!--See comment in scroll-to-text-fragment.html for why these tests have the +structure they do. --> +<script> +// This test ensure correct operation of text-fragments through both HTTP and +// client side redirects in various scenarios. + +// Constructs a URL to either redirect.py or the local client-redirect.html; +// which will cause an HTTP or client based redirect, respectively, to +// |to_url|. |type| provides a numeric 30x code to specify an HTTP redirect, +// "location" for a write to window.location, or "meta" for a <meta> refresh. +function buildRedirectUrl(to_url, type) { + let dest = ""; + to_url = encodeURIComponent(to_url); + + if (typeof type == "number") { + // If the type is a number, it's an HTTP response code, use redirect.py to + // respond with an HTTP redirect. + const code = type; + dest = `${get_host_info().ORIGIN}/common/redirect.py?status=${code}&location=${to_url}`; + } else if (type == 'meta' || type == 'location') { + // Otherwise we're requesting a client-side redirect, either a <meta> tag + // or window.location. Use the client-redirect file to bounce to the + // destination. + dest = `client-redirect.html?${type}&${to_url}`; + } + return dest; +} + +// Turns |path| from a relative-to-this-file path into a full URL. +function relativePathToFull(path) { + const pathname = window.location.toString(); + const base_path = pathname.substring(0, pathname.lastIndexOf('/') + 1); + return base_path + path; +} + +const status_codes = [301, 302, 303, 307, 308]; + +// Test that an HTTP redirect to a URL with a text fragment invokes the +// fragment. +for (let code of status_codes) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + const abs_url = relativePathToFull(`redirects-target.html?key=${key}#:~:text=target`); + const url = buildRedirectUrl(abs_url, code); + + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(url, '_blank', 'noopener'); + }); + + fetchResults(key, resolve, reject); + }).then(data => { + assert_equals(data.scrolled, true); + }), `Text fragment works from HTTP ${code} redirect.`); +} + +// Test that a URL with a text fragment that causes an HTTP redirect preserves +// the fragment and invokes it on the destination page. +for (let code of status_codes) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + const abs_url = relativePathToFull(`redirects-target.html?key=${key}`); + const url = buildRedirectUrl(abs_url, code) + "#:~:text=target"; + + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(url, '_blank', 'noopener'); + }); + + fetchResults(key, resolve, reject); + }).then(data => { + assert_equals(data.scrolled, true); + }), `Text fragment propagated through HTTP ${code} redirect.`); +} + +// Test that client-side redirects (using script) to a URL with a text fragment +// cause the text fragment to be invoked. +for (let type of ['location', 'meta']) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + const to_url = `redirects-target.html?key=${key}#:~:text=target` + const url = buildRedirectUrl(to_url, type); + + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(url, '_blank', 'noopener'); + }); + + fetchResults(key, resolve, reject); + }).then(data => { + assert_equals(data.scrolled, true); + }), `Text fragment works on client-side ${type} redirect.`); +} + +// Test that client-side redirects (using script) to a URL with a text fragment +// cause the text fragment to be invoked only the first time. A further +// redirect without a user gesture is blocked. +for (let type of ['location', 'meta']) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + const to_url = `redirects-target.html?twice&key=${key}#:~:text=target` + const url = buildRedirectUrl(to_url, type); + + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(url, '_blank', 'noopener'); + }); + + fetchResults(key, resolve, reject); + }).then(data => { + assert_equals(data.scrolled, false); + }), `One text fragment per user gesture allowed in client-side ${type} redirect.`); +} +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/application-json.json b/testing/web-platform/tests/scroll-to-text-fragment/resources/application-json.json new file mode 100644 index 0000000000..46fedf80eb --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/application-json.json @@ -0,0 +1,102 @@ +{ + "from":"https://json.org/example.html", + "web-app":{ + "servlet":[ + { + "servlet-name":"cofaxCDS", + "servlet-class":"org.cofax.cds.CDSServlet", + "init-param":{ + "configGlossary:installationAt":"Philadelphia, PA", + "configGlossary:adminEmail":"ksm@pobox.com", + "configGlossary:poweredBy":"Cofax", + "configGlossary:poweredByIcon":"/images/cofax.gif", + "configGlossary:staticPath":"/content/static", + "templateProcessorClass":"org.cofax.WysiwygTemplate", + "templateLoaderClass":"org.cofax.FilesTemplateLoader", + "templatePath":"templates", + "templateOverridePath":"", + "defaultListTemplate":"listTemplate.htm", + "defaultFileTemplate":"articleTemplate.htm", + "useJSP":false, + "jspListTemplate":"listTemplate.jsp", + "jspFileTemplate":"articleTemplate.jsp", + "cachePackageTagsTrack":200, + "cachePackageTagsStore":200, + "cachePackageTagsRefresh":60, + "cacheTemplatesTrack":100, + "cacheTemplatesStore":50, + "cacheTemplatesRefresh":15, + "cachePagesTrack":200, + "cachePagesStore":100, + "cachePagesRefresh":10, + "cachePagesDirtyRead":10, + "searchEngineListTemplate":"forSearchEnginesList.htm", + "searchEngineFileTemplate":"forSearchEngines.htm", + "searchEngineRobotsDb":"WEB-INF/robots.db", + "useDataStore":true, + "dataStoreClass":"org.cofax.SqlDataStore", + "redirectionClass":"org.cofax.SqlRedirection", + "dataStoreName":"cofax", + "dataStoreDriver":"com.microsoft.jdbc.sqlserver.SQLServerDriver", + "dataStoreUrl":"jdbc:microsoft:sqlserver://LOCALHOST:1433;DatabaseName=goon", + "dataStoreUser":"sa", + "dataStorePassword":"dataStoreTestQuery", + "dataStoreTestQuery":"SET NOCOUNT ON;select test='test';", + "dataStoreLogFile":"/usr/local/tomcat/logs/datastore.log", + "dataStoreInitConns":10, + "dataStoreMaxConns":100, + "dataStoreConnUsageLimit":100, + "dataStoreLogLevel":"debug", + "maxUrlLength":500 + } + }, + { + "servlet-name":"cofaxEmail", + "servlet-class":"org.cofax.cds.EmailServlet", + "init-param":{ + "mailHost":"mail1", + "mailHostOverride":"mail2" + } + }, + { + "servlet-name":"cofaxAdmin", + "servlet-class":"org.cofax.cds.AdminServlet" + }, + { + "servlet-name":"fileServlet", + "servlet-class":"org.cofax.cds.FileServlet" + }, + { + "servlet-name":"cofaxTools", + "servlet-class":"org.cofax.cms.CofaxToolsServlet", + "init-param":{ + "templatePath":"toolstemplates/", + "log":1, + "logLocation":"/usr/local/tomcat/logs/CofaxTools.log", + "logMaxSize":"", + "dataLog":1, + "dataLogLocation":"/usr/local/tomcat/logs/dataLog.log", + "dataLogMaxSize":"", + "removePageCache":"/content/admin/remove?cache=pages&id=", + "removeTemplateCache":"/content/admin/remove?cache=templates&id=", + "fileTransferFolder":"/usr/local/tomcat/webapps/content/fileTransferFolder", + "lookInContext":1, + "adminGroupID":4, + "betaServer":true + } + } + ], + "servlet-mapping":{ + "cofaxCDS":"/", + "cofaxEmail":"/cofaxutil/aemail/*", + "cofaxAdmin":"/admin/*", + "fileServlet":"/static/*", + "cofaxTools":"/tools/*" + }, + "taglib":{ + "taglib-uri":"cofax.tld", + "taglib-location":"/WEB-INF/tlds/cofax.tld", + "target": "foo" + } + } +} diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/application-xml.xml b/testing/web-platform/tests/scroll-to-text-fragment/resources/application-xml.xml new file mode 100644 index 0000000000..e938de2a82 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/application-xml.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<messages> + <message> + Hello World + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + The quick brown dog jumped over the lazy fox + </message> + <message> + Target + </message> +</messages> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/self-text-directive-iframe.html b/testing/web-platform/tests/scroll-to-text-fragment/resources/self-text-directive-iframe.html new file mode 100644 index 0000000000..43573692ea --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/self-text-directive-iframe.html @@ -0,0 +1,21 @@ +<!doctype html> +<!-- This document navigates itself to a text directive and messages the top window with + the resulting scroll position --> +<style> + p { + margin-top: 2000px; + } +</style> + +<script> + window.onscroll = () => { + window.top.postMessage("did_scroll", "*"); + }; + window.onload = () => { + window.location.hash = ':~:text=Target'; + }; +</script> + +<p> + Target +</p> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/text-css.css b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-css.css new file mode 100644 index 0000000000..07f029442a --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-css.css @@ -0,0 +1,132 @@ + + :root { + font-family: system-ui; + } + .valueFlex { + width: 100%; + box-sizing: border-box; + height: 40px; + border: 1px solid grey; + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + background-color: lightgrey; + + } + .valueFlex div { + height: 100%; + flex-grow: 1; + } + .valueFlex .value { + flex-grow: 4; + text-align: center; + } + + .valueFlex div div { + display: flex; + align-items: center; + justify-content: center; + } + + .valueFlex .value.disabled { + background-color: tomato; + } + .valueFlex .value.disabled div:before { + content: "Disabled"; + } + + .valueFlex .value.enabled { + background-color: chartreuse; + } + .valueFlex .value.enabled div:before { + content: "Enabled"; + } + + .valueGrid { + border: 1px solid grey; + display: grid; + grid-template-columns: 1fr 3fr; + } + + .valueGrid>div { + padding: 5px; + background-color: lightgrey; + display: flex; + justify-content: center; + align-items: center; + } + + .valueGrid>div>div { + overflow-wrap: anywhere; + } + + .valueGrid div.value { + background-color: thistle; + } + + .generator { + width: 100%; + box-sizing: border-box; + height: 40px; + border: 1px solid grey; + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + background-color: lightgrey; + } + + .items { + display: grid; + grid-template-columns: 1fr 4fr; + gap: 4px; + } + + .box { + border: 1px solid black; + } + + .box.label { + display: flex; + justify-content: center; + align-items: center; + } + + .box.listing { + padding: 10px; + display: grid; + grid-template-columns: 1fr 4fr; + row-gap: 2px; + } + + .box.listing div { + background-color: lightgrey; + } + .box.listing .value { + font-style: italic; + overflow-wrap: anywhere; + } + + .content { + } + + hr { + margin-top: 40px; + margin-bottom: 40px; + } + + #iframesContainer { + display:flex; + width: 100%; + border: 1px solid blue; + } + + #iframesContainer div { + flex: 1; + } + + #target { + width: 100px; + } + diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/text-html.html b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-html.html new file mode 100644 index 0000000000..c3d2d3a2f8 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-html.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<style> + p { + position: absolute; + top: 200vh; + } +</style> +<p> + target +</p> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/text-javascript.js b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-javascript.js new file mode 100644 index 0000000000..b75d40dcde --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-javascript.js @@ -0,0 +1,31 @@ +// Taken from https://en.wikipedia.org/wiki/JavaScript + +// Declares a function-scoped variable named `x`, and implicitly assigns the +// special value `undefined` to it. Variables without value are automatically +// set to undefined. +var x; + +// Variables can be manually set to `undefined` like so +var x2 = undefined; + +// Declares a block-scoped variable named `y`, and implicitly sets it to +// `undefined`. The `let` keyword was introduced in ECMAScript 2015. +let y; + +// Declares a block-scoped, un-reassignable variable named `z`, and sets it to +// a string literal. The `const` keyword was also introduced in ECMAScript 2015, +// and must be explicitly assigned to. + +// The keyword `const` means constant, hence the variable cannot be reassigned +// as the value is `constant`. +const z = "this value cannot be reassigned!"; + +// Declares a variable named `myNumber`, and assigns a number literal (the value +// `2`) to it. +let myNumber = 2; + +// Reassigns `myNumber`, setting it to a string literal (the value `"foo"`). +// JavaScript is a dynamically-typed language, so this is legal. +myNumber = "foo"; + +const target = "foo"; diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/text-plain.txt b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-plain.txt new file mode 100644 index 0000000000..b114445bac --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/text-plain.txt @@ -0,0 +1,27 @@ +For its first five years (1990-1995), HTML went through a number of revisions and experienced a number of extensions, primarily hosted first at CERN, and then at the IETF. + +With the creation of the W3C, HTML's development changed venue again. A first abortive attempt at extending HTML in 1995 known as HTML 3.0 then made way to a more pragmatic approach known as HTML 3.2, which was completed in 1997. HTML4 quickly followed later that same year. + +The following year, the W3C membership decided to stop evolving HTML and instead begin work on an XML-based equivalent, called XHTML. This effort started with a reformulation of HTML4 in XML, known as XHTML 1.0, which added no new features except the new serialization, and which was completed in 2000. After XHTML 1.0, the W3C's focus turned to making it easier for other working groups to extend XHTML, under the banner of XHTML Modularization. In parallel with this, the W3C also worked on a new language that was not compatible with the earlier HTML and XHTML languages, calling it XHTML2. + +Around the time that HTML's evolution was stopped in 1998, parts of the API for HTML developed by browser vendors were specified and published under the name DOM Level 1 (in 1998) and DOM Level 2 Core and DOM Level 2 HTML (starting in 2000 and culminating in 2003). These efforts then petered out, with some DOM Level 3 specifications published in 2004 but the working group being closed before all the Level 3 drafts were completed. + +In 2003, the publication of XForms, a technology which was positioned as the next generation of web forms, sparked a renewed interest in evolving HTML itself, rather than finding replacements for it. This interest was borne from the realization that XML's deployment as a web technology was limited to entirely new technologies (like RSS and later Atom), rather than as a replacement for existing deployed technologies (like HTML). + +A proof of concept to show that it was possible to extend HTML4's forms to provide many of the features that XForms 1.0 introduced, without requiring browsers to implement rendering engines that were incompatible with existing HTML web pages, was the first result of this renewed interest. At this early stage, while the draft was already publicly available, and input was already being solicited from all sources, the specification was only under Opera Software's copyright. + +The idea that HTML's evolution should be reopened was tested at a W3C workshop in 2004, where some of the principles that underlie the HTML5 work (described below), as well as the aforementioned early draft proposal covering just forms-related features, were presented to the W3C jointly by Mozilla and Opera. The proposal was rejected on the grounds that the proposal conflicted with the previously chosen direction for the web's evolution; the W3C staff and membership voted to continue developing XML-based replacements instead. + +Shortly thereafter, Apple, Mozilla, and Opera jointly announced their intent to continue working on the effort under the umbrella of a new venue called the WHATWG. A public mailing list was created, and the draft was moved to the WHATWG site. The copyright was subsequently amended to be jointly owned by all three vendors, and to allow reuse of the specification. + +The WHATWG was based on several core principles, in particular that technologies need to be backwards compatible, that specifications and implementations need to match even if this means changing the specification rather than the implementations, and that specifications need to be detailed enough that implementations can achieve complete interoperability without reverse-engineering each other. + +The latter requirement in particular required that the scope of the HTML5 specification include what had previously been specified in three separate documents: HTML4, XHTML1, and DOM2 HTML. It also meant including significantly more detail than had previously been considered the norm. + +In 2006, the W3C indicated an interest to participate in the development of HTML5 after all, and in 2007 formed a working group chartered to work with the WHATWG on the development of the HTML5 specification. Apple, Mozilla, and Opera allowed the W3C to publish the specification under the W3C copyright, while keeping a version with the less restrictive license on the WHATWG site. + +For a number of years, both groups then worked together. In 2011, however, the groups came to the conclusion that they had different goals: the W3C wanted to publish a "finished" version of "HTML5", while the WHATWG wanted to continue working on a Living Standard for HTML, continuously maintaining the specification rather than freezing it in a state with known problems, and adding new features as needed to evolve the platform. + +In 2019, the WHATWG and W3C signed an agreement to collaborate on a single version of HTML going forward: this document. + +target diff --git a/testing/web-platform/tests/scroll-to-text-fragment/resources/util.js b/testing/web-platform/tests/scroll-to-text-fragment/resources/util.js new file mode 100644 index 0000000000..00e1292988 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/resources/util.js @@ -0,0 +1,21 @@ +// Returns true if element's center is within the visual viewport bounds. +function isInViewport(element) { + const viewportRect = { + left: visualViewport.offsetLeft, + top: visualViewport.offsetTop, + right: visualViewport.offsetLeft + visualViewport.width, + bottom: visualViewport.offsetTop + visualViewport.height + }; + + const elementRect = element.getBoundingClientRect(); + const elementCenter = { + x: elementRect.left + elementRect.width / 2, + y: elementRect.top + elementRect.height / 2 + }; + + return elementCenter.x > viewportRect.left && + elementCenter.x < viewportRect.right && + elementCenter.y > viewportRect.top && + elementCenter.y < viewportRect.bottom; +} + diff --git a/testing/web-platform/tests/scroll-to-text-fragment/same-document-tests.html b/testing/web-platform/tests/scroll-to-text-fragment/same-document-tests.html new file mode 100644 index 0000000000..85db2de0a2 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/same-document-tests.html @@ -0,0 +1,61 @@ +<!doctype html> +<title>Same document navigation to text fragment directives</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="resources/util.js"></script> +<script> + function reset() { + location.hash = ''; + window.scrollTo(0, 0); + } + + function runTests() { + // Ensure a simple text directive works correctly when navigated to the + // same document using `location.hash`. + promise_test(async t => { + assert_implements(document.fragmentDirective, 'Text directive not implemented'); + reset(); + + location.hash = ':~:text=line%20of%20text'; + await t.step_wait(() => window.scrollY > 0, "Wait for scroll"); + assert_true(isInViewport(document.getElementById('text')), 'Scrolled to text'); + }, 'Basic text directive navigation'); + + // Test that we correctly fallback to the element id when we have a text + // directive that doesn't match any text in the page. + promise_test(async t => { + assert_implements(document.fragmentDirective, 'Text directive not implemented'); + reset(); + + location.hash = 'elementid:~:text=textDoesntExist'; + await t.step_wait(() => window.scrollY > 0, "Wait for scroll"); + assert_true(isInViewport(document.getElementById('elementid')), 'Scrolled to `elementid`'); + }, 'Basic element id fallback'); + + // Test that we correctly fallback to the element id when we have a text + // directive that's malformed and won't be parsed. + promise_test(async t => { + assert_implements(document.fragmentDirective, 'Text directive not implemented'); + reset(); + + location.hash = 'elementid:~:text=,,,,,'; + await t.step_wait(() => window.scrollY > 0, "Wait for scroll"); + assert_true(isInViewport(document.getElementById('elementid')), 'Scrolled to `elementid`'); + }, 'Malformed text directive element id fallback'); + } +</script> +<style> + div { + margin: 200vh 0 200vh 0; + } +</style> +<body onload="runTests()"> + <div id="text"> + This is a line of text. + </div> + <div id="elementid"> + This div has an id: 'elementid'. + </div> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-api.html b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-api.html new file mode 100644 index 0000000000..d644b6b64f --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-api.html @@ -0,0 +1,32 @@ +<!doctype html> +<title>Fragment directive API</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script> +test(t => { + assert_equals(typeof(document.fragmentDirective), 'object', 'document.fragmentDirective is defined'); +}, 'Scroll to text is feature detectable via document.fragmentDirective'); + +test(t =>{ + document.fragmentDirective = 'text=test'; + assert_equals(window.scrollY, 0, 'Setting document.fragmentDirective did not have an effect on scroll position'); + assert_equals(typeof(document.fragmentDirective), 'object', 'document.fragmentDirective is still an object type'); + assert_equals(Object.keys(document.fragmentDirective).length, 0, 'document.fragmentDirective has no properties'); +}, 'Setting document.fragmentDirective has no effect'); +</script> +<style> + body { + height: 3200px; + } + #text { + position: absolute; + top: 3000px; + } +</style> +<body> + <p id="text">This is a test page</p> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-same-doc.html b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-same-doc.html new file mode 100644 index 0000000000..378e373575 --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-same-doc.html @@ -0,0 +1,66 @@ +<!doctype html> +<title>Navigating to a same-document text fragment directive</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script> +function isInView(element) { + let rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.top <= window.innerHeight; +} + +function checkScroll(resolve) { + let position = 'unknown'; + requestAnimationFrame(() => { + if (window.scrollY == 0) + position = 'top'; + else if (isInView(document.getElementById('text'))) + position = 'text'; + resolve(position); + }); +} + +function reset() { + window.location.hash = ""; + window.scrollTo(0, 0); +} + +function runTest() { + promise_test(t => new Promise(resolve => { + reset(); + window.location.href = "#:~:text=test"; + requestAnimationFrame(function() { + checkScroll(resolve); + }); + }).then(position => { + assert_equals(position, 'text'); + assert_equals(window.location.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + }), 'Activated for same-document window.location setter'); + + promise_test(t => new Promise(resolve => { + reset(); + window.location.replace("#:~:text=test"); + requestAnimationFrame(function() { + checkScroll(resolve); + }); + }).then(position => { + assert_equals(position, 'text'); + assert_equals(window.location.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + }), 'Activated for same-document window.location.replace'); +} +</script> +<style> + body { + height: 3200px; + } + #text { + position: absolute; + top: 3000px; + } +</style> +<body onload="runTest()"> + <p id="text">This is a test page</p> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-security.sub.html b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-security.sub.html new file mode 100644 index 0000000000..5bcafed5dd --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-security.sub.html @@ -0,0 +1,80 @@ +<!doctype html> +<title>Navigating to a text fragment directive</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/common/utils.js"></script> +<script src="stash.js"></script> +<script> +// Test security restriction for user activation +for (let user_activation of [true, false]) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + if (user_activation) { + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(`scroll-to-text-fragment-target.html?key=${key}#:~:text=test`, '_blank', 'noopener'); + }); + } else { + window.open(`scroll-to-text-fragment-target.html?key=${key}#:~:text=test`, '_blank', 'noopener'); + } + + fetchResults(key, resolve, reject); + }).then(data => { + assert_equals(data.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + + if (user_activation) { + assert_equals(data.scrollPosition, 'text', 'Expected window.open() with a user activation to scroll to text.'); + } else { + assert_equals(data.scrollPosition, 'top', 'Expected window.open() with no user activation to not activate text fragment directive.'); + } + }), `Test that a text fragment directive requires a user activation (user_activation=${user_activation}).`); +} + +const crossOriginTarget = "http://{{hosts[alt][www]}}:{{ports[http][0]}}/scroll-to-text-fragment/scroll-to-text-fragment-target.html"; + +// Test security restriction for no window opener +for (let noopener of [true, false]) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + test_driver.bless('Open a URL with a text fragment directive', () => { + if (noopener) { + window.open(`${crossOriginTarget}?key=${key}#:~:text=test`, '_blank', 'noopener'); + } else { + window.open(`${crossOriginTarget}?key=${key}#:~:text=test`, '_blank'); + } + }); + + fetchResults(key, resolve, reject); + }).then(data => { + assert_equals(data.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + + if (noopener) { + assert_equals(data.scrollPosition, 'text', 'Expected window.open() with noopener to scroll to text.'); + } else { + assert_equals(data.scrollPosition, 'top', 'Expected window.open() with opener to not activate text fragment directive.'); + } + }), `Test that a text fragment directive is not activated when there is a window opener (noopener=${noopener}).`); +} + +// Test security restriction for no activation in an iframe +promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + let frame = document.createElement('iframe'); + document.body.appendChild(frame); + + test_driver.bless('Navigate the iframe with a text fragment directive', () => { + frame.src = `${crossOriginTarget}?key=${key}#:~:text=test`; + }); + + fetchResults(key, resolve, reject); +}).then(data => { + assert_equals(data.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + assert_equals(data.scrollPosition, 'top', 'Expected iframe navigation to not activate text fragment directive.'); +}), 'Test that a text fragment directive is not activated within an iframe.'); +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-target.html b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-target.html new file mode 100644 index 0000000000..b2be85132c --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment-target.html @@ -0,0 +1,99 @@ +<!doctype html> +<title>Navigating to a text fragment anchor</title> +<script src="stash.js"></script> +<script> +function isInView(element) { + let rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.top <= window.innerHeight + && rect.left >= 0 && rect.left <= window.innerWidth; +} + +function checkScroll() { + let position = 'unknown'; + if (window.scrollY == 0) + position = 'top'; + else if (isInView(document.getElementById('element'))) + position = 'element'; + else if (isInView(document.getElementById('text'))) + position = 'text'; + else if (isInView(document.getElementById('more-text'))) + position = 'more-text'; + else if (isInView(document.getElementById('cross-node-context'))) + position = 'cross-node-context'; + else if (isInView(document.getElementById('text-directive-parameters'))) + position = 'text-directive-parameters'; + else if (isInView(document.getElementById('shadow-parent'))) + position = 'shadow-parent'; + else if (isInView(document.getElementById('hidden'))) + position = 'hidden'; + else if (isInView(document.getElementById('horizontal-scroll')) && window.scrollX > 0) + position = 'horizontal-scroll'; + + let target = document.querySelector(":target"); + + if (!target && position == 'shadow-parent') { + let shadow = document.getElementById("shadow-parent").shadowRoot.firstElementChild; + if (shadow.matches(":target")) { + target = shadow; + position = 'shadow'; + } + } + + let results = { + scrollPosition: position, + href: window.location.href, + target: target ? target.id : 'undefined' + }; + + let key = (new URL(document.location)).searchParams.get("key"); + stashResultsThenClose(key, results); +} + +// Ensure two animation frames on load to test the fallback to element anchor, +// which gets queued for the next frame if the text fragment is not found. +window.onload = function() { + window.requestAnimationFrame(function() { + window.requestAnimationFrame(checkScroll); + }) +} +</script> +<style> + .scroll-section { + /* 1000px margin on top and bottom so only one section can be in view. */ + margin: 1000px 0px; + } + #hidden { + visibility: hidden; + } + #horizontal-scroll { + margin-left: 2000px; + } + #display-none { + display: none; + } +</style> +<body> + <div id="element" class="scroll-section">Element</div> + <p id="text" class="scroll-section"> + This is a test page !$'()*+./:;=?@_~ &,- ネコ + <br> + foo foo foo bar bar bar + </p> + <p id="more-text" class="scroll-section">More test page text</p> + <div class="scroll-section"> + <div> + <p>prefix</p> + <p id="cross-node-context">test page</p> + </div> + <div><p>suffix</p></div> + </div> + <p id="text-directive-parameters" class="scroll-section">this,is,test,page</p> + <div id="shadow-parent" class="scroll-section"></div> + <script> + let shadow = document.getElementById("shadow-parent").attachShadow({mode: 'open'}); + shadow.innerHTML = '<p id="shadow">shadow text</p>'; + </script> + <p id="hidden" class="scroll-section">hidden text</p> + <p id="horizontal-scroll" class="scroll-section">horizontally scrolled text</p> + <p id="display-none" class="scroll-section">display none</p> +</body> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment.html b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment.html new file mode 100644 index 0000000000..73931d4b0e --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/scroll-to-text-fragment.html @@ -0,0 +1,252 @@ +<!doctype html> +<title>Navigating to a text fragment directive</title> +<meta charset=utf-8> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/common/utils.js"></script> +<script src="stash.js"></script> +<!-- + This test suite performs scroll to text navigations to + scroll-to-text-fragment-target.html and then checks the results, which are + communicated back from the target page via the WPT Stash server (see stash.py). + This structure is necessary because scroll to text security restrictions + specifically restrict the navigator from being able to observe the result of + the navigation, e.g. the target page cannot have a window opener. +--> +<script> +let test_cases = [ + // Test non-text fragment directives + { + fragment: '#', + expect_position: 'top', + description: 'Empty hash should scroll to top' + }, + { + fragment: '#:~:text=this,is,test,page', + expect_position: 'top', + description: 'Text directive with invalid syntax (context terms without "-") should not parse as a text directive' + }, + { + fragment: '#element:~:directive', + expect_position: 'element', + description: 'Generic fragment directive with existing element fragment should scroll to element' + }, + { + fragment: '#:~:TEXT=test', + expect_position: 'top', + description: 'Uppercase TEXT directive should not parse as a text directive' + }, + // Test exact text matching, with all combinations of context terms + { + fragment: '#:~:text=test', + expect_position: 'text', + description: 'Exact text with no context should match text' + }, + { + fragment: '#:~:text=this is a-,test', + expect_position: 'text', + description: 'Exact text with prefix should match text' + }, + { + fragment: '#:~:text=test,-page', + expect_position: 'text', + description: 'Exact text with suffix should match text' + }, + { + fragment: '#:~:text=this is a-,test,-page', + expect_position: 'text', + description: 'Exact text with prefix and suffix should match text' + }, + // Test tricky edge case where prefix and query are equal + { + fragment: '#:~:text=foo-,foo,-bar', + expect_position: 'text', + description: 'Exact text with prefix and suffix and query equals prefix.' + }, + // Test text range matching, with all combinations of context terms + { + fragment: '#:~:text=this,page', + expect_position: 'text', + description: 'Text range with no context should match text' + }, + { + fragment: '#:~:text=this-,is,test', + expect_position: 'text', + description: 'Text range with prefix should match text' + }, + { + fragment: '#:~:text=this,test,-page', + expect_position: 'text', + description: 'Text range with suffix should match text' + }, + { + fragment: '#:~:text=this-,is,test,-page', + expect_position: 'text', + description: 'Text range with prefix and suffix should match text' + }, + // Test partially non-matching text ranges + { + fragment: '#:~:text=this,none', + expect_position: 'top', + description: 'Text range with non-matching endText should not match' + }, + { + fragment: '#:~:text=none,page', + expect_position: 'top', + description: 'Text range with non-matching startText should not match' + }, + // Test non-matching context terms + { + fragment: '#:~:text=this-,is,page,-none', + expect_position: 'top', + description: 'Text range with prefix and nonmatching suffix should not match' + }, + { + fragment: '#:~:text=none-,this,test,-page', + expect_position: 'top', + description: 'Text range with nonmatching prefix and matching suffix should not match' + }, + // Test percent encoded characters + { + fragment: '#:~:text=this%20is%20a%20test%20page', + expect_position: 'text', + description: 'Exact text with percent encoded spaces should match text' + }, + { + fragment: '#:~:text=test%20pag', + expect_position: 'top', + description: 'Non-whole-word exact text with spaces should not match' + }, + { + fragment: '#:~:text=%26%2C%2D', + expect_position: 'text', + description: 'Fragment directive with percent encoded syntactical characters "&,-" should match text' + }, + { + fragment: '#:~:text=%E3%83%8D%E3%82%B3', + expect_position: 'text', + description: 'Fragment directive with percent encoded non-ASCII unicode character should match text' + }, + { + fragment: '#:~:text=!$\'()*+./:;=?@_~', + expect_position: 'text', + description: 'Fragment directive with all TextMatchChars should match text' + }, + // Test multiple text directives + { + fragment: '#:~:text=this&text=test,page', + expect_position: 'text', + description: 'Multiple matching exact texts should match text' + }, + { + fragment: '#:~:text=tes&text=age', + expect_position: 'top', + description: 'Multiple non-whole-word exact texts should not match' + }, + { + fragment: '#:~:text=none&text=test%20page', + expect_position: 'text', + description: 'A non-matching text directive followed by a matching text directive should match and scroll into view the second text directive' + }, + { + fragment: '#:~:text=test%20page&directive', + expect_position: 'text', + description: 'Text directive followed by non-text directive should match text' + }, + { + fragment: '#:~:text=test&directive&text=page', + expect_position: 'text', + description: 'Multiple text directives and a non-text directive should match text' + }, + // Test text directive behavior when there's an element fragment identifier + { + fragment: '#element:~:text=test', + expect_position: 'text', + description: 'Text directive with existing element fragment should match and scroll into view text' + }, + { + fragment: '#pagestate:~:text=test', + expect_position: 'text', + description: 'Text directive with nonexistent element fragment should match and scroll into view text' + }, + { + fragment: '#element:~:text=nomatch', + expect_position: 'element', + description: 'Non-matching text directive with existing element fragment should scroll to element' + }, + { + fragment: '#pagestate:~:text=nomatch', + expect_position: 'top', + description: 'Non-matching text directive with nonexistent element fragment should not match and not scroll' + }, + // Test ambiguous text matches disambiguated by context terms + { + fragment: '#:~:text=more-,test%20page', + expect_position: 'more-text', + description: 'Multiple match text directive disambiguated by prefix should match the prefixed text' + }, + { + fragment: '#:~:text=test%20page,-text', + expect_position: 'more-text', + description: 'Multiple match text directive disambiguated by suffix should match the suffixed text' + }, + { + fragment: '#:~:text=more-,test%20page,-text', + expect_position: 'more-text', + description: 'Multiple match text directive disambiguated by prefix and suffix should match the text with the given context' + }, + // Test context terms separated by node boundaries + { + fragment: '#:~:text=prefix-,test%20page,-suffix', + expect_position: 'cross-node-context', + description: 'Text directive should match when context terms are separated by node boundaries' + }, + // Test text directive within shadow DOM + { + fragment: '#:~:text=shadow%20text', + expect_position: 'shadow', + description: 'Text directive should match text within shadow DOM' + }, + // Test text directive within hidden and display none elements. These cases should not scroll into + // view, but still "match" in that they should be highlighted or otherwise visibly indicated + // if they were to become visible. + { + fragment: '#:~:text=hidden%20text', + expect_position: 'top', + description: 'Text directive should not scroll to hidden text' + }, + { + fragment: '#:~:text=display%20none', + expect_position: 'top', + description: 'Text directive should not scroll to display none text' + }, + // Test horizontal scroll into view + { + fragment: '#:~:text=horizontally%20scrolled%20text', + expect_position: 'horizontal-scroll', + description: 'Text directive should horizontally scroll into view' + } +]; + +for (const test_case of test_cases) { + promise_test(t => new Promise((resolve, reject) => { + let key = token(); + + test_driver.bless('Open a URL with a text fragment directive', () => { + window.open(`scroll-to-text-fragment-target.html?key=${key}${test_case.fragment}`, '_blank', 'noopener'); + }); + + fetchResults(key, resolve, reject); + }).then(data => { + // If the position is not 'top', the :target element should be the positioned element. + assert_true(data.scrollPosition == 'top' || data.target == data.scrollPosition); + assert_equals(data.href.indexOf(':~:'), -1, 'Expected fragment directive to be stripped from the URL.'); + assert_equals(data.scrollPosition, test_case.expect_position, + `Expected ${test_case.fragment} (${test_case.description}) to scroll to ${test_case.expect_position}.`); + }), `Test navigation with fragment: ${test_case.description}.`); +} +</script> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/stash.js b/testing/web-platform/tests/scroll-to-text-fragment/stash.js new file mode 100644 index 0000000000..f1b2ea8d3a --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/stash.js @@ -0,0 +1,29 @@ +// Put test results into Stash +function stashResultsThenClose(key, results) { + fetch(`/scroll-to-text-fragment/stash.py?key=${key}`, { + method: 'POST', + body: JSON.stringify(results) + }).then(() => { + window.close(); + }); +} + +// Fetch test results from the Stash +function fetchResults(key, resolve, reject) { + fetch(`/scroll-to-text-fragment/stash.py?key=${key}`).then(response => { + return response.text(); + }).then(text => { + if (text) { + try { + const results = JSON.parse(text); + resolve(results); + } catch(e) { + reject(); + } + } else { + // We keep trying to fetch results as the target page may not have stashed + // them yet. + fetchResults(key, resolve, reject); + } + }); +} diff --git a/testing/web-platform/tests/scroll-to-text-fragment/stash.py b/testing/web-platform/tests/scroll-to-text-fragment/stash.py new file mode 100644 index 0000000000..f66f32ad8d --- /dev/null +++ b/testing/web-platform/tests/scroll-to-text-fragment/stash.py @@ -0,0 +1,13 @@ +import time + +def main(request, response): + key = request.GET.first(b"key") + + if request.method == u"POST": + # Received result data from target page + request.server.stash.put(key, request.body, u'/scroll-to-text-fragment/') + return u"ok" + else: + # Request for result data from test page + value = request.server.stash.take(key, u'/scroll-to-text-fragment/') + return value |